| | "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> |
| | ); |
| | } |
| |
|