|
|
"use client"; |
|
|
|
|
|
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; |
|
|
import "leaflet/dist/leaflet.css"; |
|
|
import { useEffect, useState, useRef } from "react"; |
|
|
import { Layers, X, Eye, EyeOff, Trash2, ChevronDown, ChevronUp, MoreHorizontal, Settings, GripVertical } from "lucide-react"; |
|
|
import L from "leaflet"; |
|
|
import { |
|
|
DndContext, |
|
|
closestCenter, |
|
|
KeyboardSensor, |
|
|
PointerSensor, |
|
|
useSensor, |
|
|
useSensors, |
|
|
DragEndEvent |
|
|
} from '@dnd-kit/core'; |
|
|
import { |
|
|
arrayMove, |
|
|
SortableContext, |
|
|
sortableKeyboardCoordinates, |
|
|
verticalListSortingStrategy, |
|
|
useSortable |
|
|
} from '@dnd-kit/sortable'; |
|
|
import { CSS } from '@dnd-kit/utilities'; |
|
|
|
|
|
|
|
|
delete (L.Icon.Default.prototype as any)._getIconUrl; |
|
|
L.Icon.Default.mergeOptions({ |
|
|
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", |
|
|
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", |
|
|
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", |
|
|
}); |
|
|
|
|
|
export interface MapLayer { |
|
|
id: string; |
|
|
name: string; |
|
|
data: any; |
|
|
visible: boolean; |
|
|
style: { |
|
|
color: string; |
|
|
fillColor: string; |
|
|
fillOpacity: number; |
|
|
weight: number; |
|
|
opacity?: number; |
|
|
}; |
|
|
|
|
|
choropleth?: { |
|
|
enabled: boolean; |
|
|
column: string; |
|
|
palette: string; |
|
|
scale?: 'linear' | 'log'; |
|
|
min?: number; |
|
|
max?: number; |
|
|
}; |
|
|
|
|
|
geometryType?: 'polygon' | 'point' | 'line'; |
|
|
|
|
|
pointMarker?: { |
|
|
icon: string; |
|
|
color: string; |
|
|
size: number; |
|
|
style?: string; |
|
|
}; |
|
|
} |
|
|
|
|
|
interface MapViewerProps { |
|
|
layers: MapLayer[]; |
|
|
onLayerUpdate: (id: string, updates: Partial<MapLayer>) => void; |
|
|
onLayerRemove: (id: string) => void; |
|
|
onLayerReorder: (fromIndex: number, toIndex: number) => void; |
|
|
} |
|
|
|
|
|
|
|
|
const COLOR_PALETTES: Record<string, string[]> = { |
|
|
viridis: ['#440154', '#482878', '#3e4a89', '#31688e', '#26828e', '#1f9e89', '#35b779', '#6ece58', '#b5de2b', '#fde725'], |
|
|
blues: ['#f7fbff', '#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#08519c', '#08306b'], |
|
|
reds: ['#fff5f0', '#fee0d2', '#fcbba1', '#fc9272', '#fb6a4a', '#ef3b2c', '#cb181d', '#a50f15', '#67000d'], |
|
|
greens: ['#f7fcf5', '#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#41ab5d', '#238b45', '#006d2c', '#00441b'], |
|
|
oranges: ['#fff5eb', '#fee6ce', '#fdd0a2', '#fdae6b', '#fd8d3c', '#f16913', '#d94801', '#a63603', '#7f2704'], |
|
|
}; |
|
|
|
|
|
function getChoroplethColor(value: number, min: number, max: number, palette: string, scale: 'linear' | 'log' = 'linear'): string { |
|
|
const colors = COLOR_PALETTES[palette] || COLOR_PALETTES.viridis; |
|
|
if (value <= min) return colors[0]; |
|
|
if (value >= max) return colors[colors.length - 1]; |
|
|
|
|
|
let normalized: number; |
|
|
|
|
|
if (scale === 'log' && min > 0 && max > 0) { |
|
|
|
|
|
const logMin = Math.log(min); |
|
|
const logMax = Math.log(max); |
|
|
const logVal = Math.log(Math.max(min, value)); |
|
|
normalized = (logVal - logMin) / (logMax - logMin); |
|
|
} else { |
|
|
|
|
|
normalized = (value - min) / (max - min); |
|
|
} |
|
|
|
|
|
|
|
|
normalized = Math.max(0, Math.min(1, normalized)); |
|
|
|
|
|
|
|
|
const index = Math.floor(normalized * (colors.length - 1)); |
|
|
return colors[index]; |
|
|
} |
|
|
|
|
|
function calculateMinMax(features: any[], column: string): { min: number; max: number } { |
|
|
let min = Infinity; |
|
|
let max = -Infinity; |
|
|
|
|
|
features.forEach((feature: any) => { |
|
|
const value = feature.properties?.[column]; |
|
|
if (typeof value === 'number' && !isNaN(value)) { |
|
|
min = Math.min(min, value); |
|
|
max = Math.max(max, value); |
|
|
} |
|
|
}); |
|
|
|
|
|
return { min: min === Infinity ? 0 : min, max: max === -Infinity ? 1 : max }; |
|
|
} |
|
|
|
|
|
|
|
|
interface SortableLayerItemProps { |
|
|
layer: MapLayer; |
|
|
onUpdate: (id: string, updates: Partial<MapLayer>) => void; |
|
|
onRemove: (id: string) => void; |
|
|
expandedId: string | null; |
|
|
setExpandedId: (id: string | null) => void; |
|
|
} |
|
|
|
|
|
function SortableLayerItem({ layer, onUpdate, onRemove, expandedId, setExpandedId }: SortableLayerItemProps) { |
|
|
const { |
|
|
attributes, |
|
|
listeners, |
|
|
setNodeRef, |
|
|
transform, |
|
|
transition, |
|
|
isDragging |
|
|
} = useSortable({ id: layer.id }); |
|
|
|
|
|
const style = { |
|
|
transform: CSS.Transform.toString(transform), |
|
|
transition, |
|
|
zIndex: isDragging ? 10 : 'auto', |
|
|
opacity: isDragging ? 0.5 : 1 |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div ref={setNodeRef} style={style} className={`bg-slate-50 rounded-lg p-3 border ${isDragging ? 'border-indigo-400 shadow-md' : 'border-slate-100'}`}> |
|
|
<div className="flex items-center justify-between mb-2"> |
|
|
<div className="flex items-center gap-2 overflow-hidden flex-1"> |
|
|
{/* Drag Handle */} |
|
|
<div |
|
|
{...attributes} |
|
|
{...listeners} |
|
|
className="cursor-move text-slate-400 hover:text-slate-600 p-0.5 rounded hover:bg-slate-200" |
|
|
title="Drag to reorder" |
|
|
> |
|
|
<GripVertical className="w-4 h-4" /> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
className="w-3 h-3 rounded-full shrink-0" |
|
|
style={{ backgroundColor: layer.style.color }} |
|
|
/> |
|
|
<span className="text-sm font-medium text-slate-700 overflow-x-auto whitespace-nowrap scrollbar-none" title={layer.name}> |
|
|
{layer.name} |
|
|
</span> |
|
|
</div> |
|
|
<div className="flex items-center gap-1"> |
|
|
<button |
|
|
onClick={() => onUpdate(layer.id, { visible: !layer.visible })} |
|
|
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded transition-colors" |
|
|
title={layer.visible ? "Hide layer" : "Show layer"} |
|
|
> |
|
|
{layer.visible ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} |
|
|
</button> |
|
|
<button |
|
|
onClick={() => setExpandedId(expandedId === layer.id ? null : layer.id)} |
|
|
className={`p-1 hover:text-slate-600 hover:bg-slate-200 rounded transition-colors ${expandedId === layer.id ? 'text-indigo-600 bg-indigo-50' : 'text-slate-400'}`} |
|
|
title="Layer settings" |
|
|
> |
|
|
{expandedId === layer.id ? <ChevronUp className="w-3.5 h-3.5" /> : <Settings className="w-3.5 h-3.5" />} |
|
|
</button> |
|
|
<button |
|
|
onClick={() => onRemove(layer.id)} |
|
|
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors" |
|
|
title="Remove layer" |
|
|
> |
|
|
<Trash2 className="w-3.5 h-3.5" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Layer Settings (Expanded) */} |
|
|
{expandedId === layer.id && ( |
|
|
<div className="pt-2 mt-2 border-t border-slate-200 space-y-4 animation-slide-down"> |
|
|
{/* Fill Settings */} |
|
|
<div className="space-y-2"> |
|
|
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Fill Style</label> |
|
|
<div className="flex items-center justify-between"> |
|
|
<label className="text-xs text-slate-500">Color</label> |
|
|
<div className="flex items-center gap-2"> |
|
|
<input |
|
|
type="color" |
|
|
value={layer.style.fillColor} |
|
|
onChange={(e) => onUpdate(layer.id, { |
|
|
style: { ...layer.style, fillColor: e.target.value } |
|
|
})} |
|
|
className="w-6 h-6 rounded cursor-pointer border-0 p-0" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<div className="flex items-center justify-between mb-1"> |
|
|
<label className="text-xs text-slate-500">Opacity</label> |
|
|
<span className="text-xs text-slate-400">{Math.round((layer.style.fillOpacity ?? 0.6) * 100)}%</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.1" |
|
|
value={layer.style.fillOpacity ?? 0.6} |
|
|
onChange={(e) => onUpdate(layer.id, { |
|
|
style: { ...layer.style, fillOpacity: parseFloat(e.target.value) } |
|
|
})} |
|
|
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Border Settings */} |
|
|
<div className="space-y-2 pt-2 border-t border-slate-100"> |
|
|
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Border Style</label> |
|
|
|
|
|
{/* Border Color */} |
|
|
<div className="flex items-center justify-between"> |
|
|
<label className="text-xs text-slate-500">Color</label> |
|
|
<div className="flex items-center gap-2"> |
|
|
<input |
|
|
type="color" |
|
|
value={layer.style.color} |
|
|
onChange={(e) => onUpdate(layer.id, { |
|
|
style: { ...layer.style, color: e.target.value } |
|
|
})} |
|
|
className="w-6 h-6 rounded cursor-pointer border-0 p-0" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Border Width */} |
|
|
<div> |
|
|
<div className="flex items-center justify-between mb-1"> |
|
|
<label className="text-xs text-slate-500">Width</label> |
|
|
<span className="text-xs text-slate-400">{layer.style.weight ?? 1}px</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" |
|
|
min="0" |
|
|
max="5" |
|
|
step="0.5" |
|
|
value={layer.style.weight ?? 1} |
|
|
onChange={(e) => onUpdate(layer.id, { |
|
|
style: { ...layer.style, weight: parseFloat(e.target.value) } |
|
|
})} |
|
|
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
{/* Border Opacity (using style.opacity which Leaflet uses for stroke opacity) */} |
|
|
<div> |
|
|
<div className="flex items-center justify-between mb-1"> |
|
|
<label className="text-xs text-slate-500">Opacity</label> |
|
|
<span className="text-xs text-slate-400">{Math.round((layer.style.opacity ?? 1) * 100)}%</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.1" |
|
|
value={layer.style.opacity ?? 1} |
|
|
onChange={(e) => onUpdate(layer.id, { |
|
|
style: { ...layer.style, opacity: parseFloat(e.target.value) } |
|
|
})} |
|
|
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
function AutoFitBounds({ layers }: { layers: MapLayer[] }) { |
|
|
const map = useMap(); |
|
|
const prevLayersLength = useRef(0); |
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
if (layers.length > prevLayersLength.current) { |
|
|
const latestLayer = layers[layers.length - 1]; |
|
|
if (latestLayer && latestLayer.visible && latestLayer.data) { |
|
|
try { |
|
|
const geoJsonLayer = L.geoJSON(latestLayer.data); |
|
|
const bounds = geoJsonLayer.getBounds(); |
|
|
if (bounds.isValid()) { |
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 }); |
|
|
} |
|
|
} catch (e) { |
|
|
console.error("Error fitting bounds:", e); |
|
|
} |
|
|
} |
|
|
} |
|
|
prevLayersLength.current = layers.length; |
|
|
}, [layers, map]); |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
function onEachFeature(feature: any, layer: L.Layer) { |
|
|
if (feature.properties) { |
|
|
const props = feature.properties; |
|
|
|
|
|
|
|
|
let popupContent = ` |
|
|
<div style="min-width: 200px; font-family: system-ui, sans-serif;"> |
|
|
`; |
|
|
|
|
|
|
|
|
const title = props.adm3_name || props.adm2_name || props.adm1_name || props.name || "Feature"; |
|
|
const adminLevel = props.adm3_name ? "Corregimiento" : props.adm2_name ? "District" : props.adm1_name ? "Province" : ""; |
|
|
|
|
|
popupContent += ` |
|
|
<div style="border-bottom: 2px solid #6366f1; padding-bottom: 8px; margin-bottom: 8px;"> |
|
|
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">${title}</div> |
|
|
${adminLevel ? `<div style="font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px;">${adminLevel}</div>` : ''} |
|
|
</div> |
|
|
`; |
|
|
|
|
|
|
|
|
const hierarchyItems = []; |
|
|
if (props.adm3_name && props.adm2_name) hierarchyItems.push(`📍 District: ${props.adm2_name}`); |
|
|
if ((props.adm3_name || props.adm2_name) && props.adm1_name) hierarchyItems.push(`🏛️ Province: ${props.adm1_name}`); |
|
|
|
|
|
if (hierarchyItems.length > 0) { |
|
|
popupContent += `<div style="font-size: 12px; color: #475569; margin-bottom: 8px;">`; |
|
|
hierarchyItems.forEach(item => { |
|
|
popupContent += `<div>${item}</div>`; |
|
|
}); |
|
|
popupContent += `</div>`; |
|
|
} |
|
|
|
|
|
|
|
|
const metrics = []; |
|
|
if (props.area_sqkm) { |
|
|
const area = typeof props.area_sqkm === 'number' ? props.area_sqkm : parseFloat(props.area_sqkm); |
|
|
metrics.push({ label: "Area", value: `${area.toLocaleString(undefined, { maximumFractionDigits: 1 })} km²` }); |
|
|
} |
|
|
if (props.area) { |
|
|
const area = typeof props.area === 'number' ? props.area : parseFloat(props.area); |
|
|
metrics.push({ label: "Area", value: `${area.toLocaleString(undefined, { maximumFractionDigits: 1 })} km²` }); |
|
|
} |
|
|
if (props.population) { |
|
|
metrics.push({ label: "Population", value: props.population.toLocaleString() }); |
|
|
} |
|
|
|
|
|
if (metrics.length > 0) { |
|
|
popupContent += `<div style="background: #f1f5f9; border-radius: 6px; padding: 8px; margin-bottom: 8px;">`; |
|
|
metrics.forEach(m => { |
|
|
popupContent += ` |
|
|
<div style="display: flex; justify-content: space-between; font-size: 12px;"> |
|
|
<span style="color: #64748b;">${m.label}</span> |
|
|
<span style="font-weight: 500; color: #0f172a;">${m.value}</span> |
|
|
</div> |
|
|
`; |
|
|
}); |
|
|
popupContent += `</div>`; |
|
|
} |
|
|
|
|
|
|
|
|
const excludedKeys = ['name', 'adm0_name', 'adm1_name', 'adm2_name', 'adm3_name', 'area_sqkm', 'area', 'population', |
|
|
'geometry', 'geom', 'layer_name', 'layer_id', 'style', 'adm0_pcode', 'adm1_pcode', 'adm2_pcode', 'adm3_pcode']; |
|
|
const additionalProps = Object.entries(props).filter(([key, value]) => |
|
|
!excludedKeys.includes(key) && |
|
|
(typeof value === 'string' || typeof value === 'number') && |
|
|
String(value).length < 50 |
|
|
); |
|
|
|
|
|
if (additionalProps.length > 0) { |
|
|
popupContent += `<div style="font-size: 11px; color: #64748b; border-top: 1px solid #e2e8f0; padding-top: 6px;">`; |
|
|
additionalProps.slice(0, 5).forEach(([key, value]) => { |
|
|
const cleanKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); |
|
|
popupContent += `<div><span style="color: #94a3b8;">${cleanKey}:</span> ${value}</div>`; |
|
|
}); |
|
|
popupContent += `</div>`; |
|
|
} |
|
|
|
|
|
popupContent += `</div>`; |
|
|
layer.bindPopup(popupContent, { maxWidth: 300 }); |
|
|
} |
|
|
} |
|
|
|
|
|
export default function MapViewer({ layers, onLayerUpdate, onLayerRemove, onLayerReorder }: MapViewerProps) { |
|
|
const [showLegend, setShowLegend] = useState(true); |
|
|
const [expandedLayerId, setExpandedLayerId] = useState<string | null>(null); |
|
|
const [dismissedEmptyState, setDismissedEmptyState] = useState(false); |
|
|
|
|
|
|
|
|
|
|
|
const reversedLayers = [...layers].reverse(); |
|
|
|
|
|
const sensors = useSensors( |
|
|
useSensor(PointerSensor, { |
|
|
activationConstraint: { |
|
|
distance: 8, |
|
|
}, |
|
|
}), |
|
|
useSensor(KeyboardSensor, { |
|
|
coordinateGetter: sortableKeyboardCoordinates, |
|
|
}) |
|
|
); |
|
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => { |
|
|
const { active, over } = event; |
|
|
|
|
|
if (over && active.id !== over.id) { |
|
|
const oldVisualIndex = reversedLayers.findIndex((l) => l.id === active.id); |
|
|
const newVisualIndex = reversedLayers.findIndex((l) => l.id === over.id); |
|
|
|
|
|
|
|
|
|
|
|
const fromIndex = layers.length - 1 - oldVisualIndex; |
|
|
const toIndex = layers.length - 1 - newVisualIndex; |
|
|
|
|
|
onLayerReorder(fromIndex, toIndex); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="h-full w-full relative z-0"> |
|
|
<MapContainer |
|
|
center={[8.98, -79.5]} // Panama City |
|
|
zoom={8} |
|
|
scrollWheelZoom={true} |
|
|
style={{ height: "100%", width: "100%" }} |
|
|
className="z-0" |
|
|
> |
|
|
<TileLayer |
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' |
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" |
|
|
/> |
|
|
|
|
|
{/* Render All Visible Layers */} |
|
|
{layers.map(layer => { |
|
|
if (!layer.visible || !layer.data) return null; |
|
|
|
|
|
// Calculate min/max for choropleth if enabled |
|
|
let choroplethConfig: { min: number; max: number; column: string; palette: string; scale: 'linear' | 'log' } | null = null; |
|
|
if (layer.choropleth?.enabled && layer.data.features) { |
|
|
const { min, max } = calculateMinMax(layer.data.features, layer.choropleth.column); |
|
|
choroplethConfig = { |
|
|
min: layer.choropleth.min ?? min, |
|
|
max: layer.choropleth.max ?? max, |
|
|
column: layer.choropleth.column, |
|
|
palette: layer.choropleth.palette, |
|
|
scale: layer.choropleth.scale || 'linear' |
|
|
}; |
|
|
} |
|
|
|
|
|
// Create point marker function for POI layers |
|
|
const pointToLayer = (feature: any, latlng: L.LatLng) => { |
|
|
const props = feature.properties || {}; |
|
|
const iconChar = props.icon || layer.pointMarker?.icon; |
|
|
const color = layer.pointMarker?.color || layer.style.color || '#6366f1'; |
|
|
const size = layer.pointMarker?.size || 24; |
|
|
|
|
|
// Mode 1: Circle markers for dense data (heatmap-style) |
|
|
// Use circles when: explicitly requested, no icon provided, or icon is "dot"/"circle" |
|
|
if (!iconChar || iconChar === "dot" || iconChar === "circle" || layer.pointMarker?.style === "circle") { |
|
|
return L.circleMarker(latlng, { |
|
|
radius: size / 4 || 5, // Convert size to radius |
|
|
fillColor: color, |
|
|
color: '#ffffff', |
|
|
weight: 2, |
|
|
opacity: 1, |
|
|
fillOpacity: 0.7 |
|
|
}); |
|
|
} |
|
|
|
|
|
// Mode 2: Icon markers for sparse, semantic features (hospitals, mountains, etc.) |
|
|
const divIcon = L.divIcon({ |
|
|
html: `<div style=" |
|
|
font-size: ${size}px; |
|
|
line-height: 1; |
|
|
text-align: center; |
|
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); |
|
|
">${iconChar}</div>`, |
|
|
className: 'custom-emoji-marker', |
|
|
iconSize: [size, size], |
|
|
iconAnchor: [size / 2, size / 2], |
|
|
popupAnchor: [0, -size / 2] |
|
|
}); |
|
|
|
|
|
return L.marker(latlng, { icon: divIcon }); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<GeoJSON |
|
|
key={layer.id + JSON.stringify(layer.style) + JSON.stringify(layer.choropleth)} |
|
|
data={layer.data} |
|
|
pointToLayer={pointToLayer} |
|
|
style={(feature) => { |
|
|
// If choropleth is enabled, color by value |
|
|
if (choroplethConfig && feature?.properties) { |
|
|
const value = feature.properties[choroplethConfig.column]; |
|
|
if (typeof value === 'number') { |
|
|
const fillColor = getChoroplethColor( |
|
|
value, |
|
|
choroplethConfig.min, |
|
|
choroplethConfig.max, |
|
|
choroplethConfig.palette, |
|
|
choroplethConfig.scale |
|
|
); |
|
|
return { |
|
|
fillColor, |
|
|
fillOpacity: layer.style.fillOpacity ?? 0.7, |
|
|
color: layer.style.color || '#333', |
|
|
weight: layer.style.weight ?? 1, |
|
|
opacity: layer.style.opacity ?? 1 |
|
|
}; |
|
|
} |
|
|
} |
|
|
// Line styling |
|
|
if (feature?.geometry?.type === 'LineString' || feature?.geometry?.type === 'MultiLineString') { |
|
|
return { |
|
|
color: layer.style.color, |
|
|
weight: layer.style.weight || 3, |
|
|
opacity: layer.style.opacity ?? 0.8 |
|
|
}; |
|
|
} |
|
|
// Default polygon style |
|
|
return { |
|
|
fillColor: layer.style.fillColor ?? layer.style.color, |
|
|
fillOpacity: layer.style.fillOpacity ?? 0.6, |
|
|
color: layer.style.color, |
|
|
weight: layer.style.weight ?? 1, |
|
|
opacity: layer.style.opacity ?? 1 |
|
|
}; |
|
|
}} |
|
|
onEachFeature={onEachFeature} |
|
|
/> |
|
|
); |
|
|
})} |
|
|
|
|
|
<AutoFitBounds layers={layers} /> |
|
|
</MapContainer> |
|
|
|
|
|
{/* Layer Control Panel */} |
|
|
{showLegend && layers.length > 0 && ( |
|
|
<div className="absolute top-4 right-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200 p-4 w-72 max-h-[calc(100vh-2rem)] overflow-y-auto"> |
|
|
<div className="flex items-center justify-between mb-3 border-b border-slate-100 pb-2"> |
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700"> |
|
|
<Layers className="w-4 h-4 text-indigo-600" /> |
|
|
Layers ({layers.length}) |
|
|
</div> |
|
|
<button |
|
|
onClick={() => setShowLegend(false)} |
|
|
className="text-slate-400 hover:text-slate-600 transition-colors" |
|
|
> |
|
|
<X className="w-4 h-4" /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<DndContext |
|
|
sensors={sensors} |
|
|
collisionDetection={closestCenter} |
|
|
onDragEnd={handleDragEnd} |
|
|
> |
|
|
<SortableContext |
|
|
items={reversedLayers.map(l => l.id)} |
|
|
strategy={verticalListSortingStrategy} |
|
|
> |
|
|
<div className="space-y-3"> |
|
|
{reversedLayers.map((layer) => ( |
|
|
<SortableLayerItem |
|
|
key={layer.id} |
|
|
layer={layer} |
|
|
onUpdate={onLayerUpdate} |
|
|
onRemove={onLayerRemove} |
|
|
expandedId={expandedLayerId} |
|
|
setExpandedId={setExpandedLayerId} |
|
|
/> |
|
|
))} |
|
|
</div> |
|
|
</SortableContext> |
|
|
</DndContext> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Toggle legend button when hidden */} |
|
|
{!showLegend && layers.length > 0 && ( |
|
|
<button |
|
|
onClick={() => setShowLegend(true)} |
|
|
className="absolute top-4 right-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200 p-3 hover:bg-slate-50 transition-colors" |
|
|
title="Show layers" |
|
|
> |
|
|
<Layers className="w-5 h-5 text-indigo-600" /> |
|
|
<span className="ml-2 text-xs font-medium text-slate-600">{layers.length}</span> |
|
|
</button> |
|
|
)} |
|
|
|
|
|
{/* Empty state overlay */} |
|
|
{layers.length === 0 && !dismissedEmptyState && ( |
|
|
<div className="absolute inset-0 flex items-center justify-center z-[500]"> |
|
|
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg border border-slate-200 p-6 text-center max-w-sm relative"> |
|
|
<button |
|
|
onClick={() => setDismissedEmptyState(true)} |
|
|
className="absolute top-2 right-2 text-slate-400 hover:text-slate-600 transition-colors p-1 rounded-lg hover:bg-slate-100" |
|
|
title="Dismiss" |
|
|
> |
|
|
<X className="w-4 h-4" /> |
|
|
</button> |
|
|
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mx-auto mb-3"> |
|
|
<Layers className="w-6 h-6 text-indigo-600" /> |
|
|
</div> |
|
|
<h3 className="font-semibold text-slate-700 mb-1">No Data Displayed</h3> |
|
|
<p className="text-sm text-slate-500"> |
|
|
Ask GeoQuery about population, districts, or coverage to see data on the map. |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|