Download frontend/src/components/MapViewer.tsx from GerardCB/GeoQuery: direct link, hf CLI and curl.
- Browser
- Download file 30.4 kB
-
https://huggingface.co/spaces/GerardCB/GeoQuery/resolve/main/frontend/src/components/MapViewer.tsx
- Command line
-
hf download hf://spaces/GerardCB/GeoQuery/frontend/src/components/MapViewer.tsx
-
curl -L -o MapViewer.tsx https://huggingface.co/spaces/GerardCB/GeoQuery/resolve/main/frontend/src/components/MapViewer.tsx
30.4 kB
| "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'; | |
| // Fix for default marker icons in Next.js | |
| 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 configuration | |
| choropleth?: { | |
| enabled: boolean; | |
| column: string; | |
| palette: string; | |
| scale?: 'linear' | 'log'; | |
| min?: number; | |
| max?: number; | |
| }; | |
| // Geometry type for styling | |
| geometryType?: 'polygon' | 'point' | 'line'; | |
| // Point marker configuration | |
| pointMarker?: { | |
| icon: string; // emoji or icon class | |
| color: string; | |
| size: number; | |
| style?: string; // "circle" or undefined | |
| }; | |
| } | |
| interface MapViewerProps { | |
| layers: MapLayer[]; | |
| onLayerUpdate: (id: string, updates: Partial<MapLayer>) => void; | |
| onLayerRemove: (id: string) => void; | |
| onLayerReorder: (fromIndex: number, toIndex: number) => void; | |
| } | |
| // ============== Choropleth Color Scales ================= | |
| 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) { | |
| // Logarithmic normalization | |
| const logMin = Math.log(min); | |
| const logMax = Math.log(max); | |
| const logVal = Math.log(Math.max(min, value)); // avoid log(0) | |
| normalized = (logVal - logMin) / (logMax - logMin); | |
| } else { | |
| // Linear normalization | |
| normalized = (value - min) / (max - min); | |
| } | |
| // Clamp | |
| normalized = Math.max(0, Math.min(1, normalized)); | |
| // Map to color index | |
| 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 }; | |
| } | |
| // ============== Sortable Item Component ================= | |
| 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> | |
| ); | |
| } | |
| // Component to fit map bounds to the latest added layer | |
| function AutoFitBounds({ layers }: { layers: MapLayer[] }) { | |
| const map = useMap(); | |
| const prevLayersLength = useRef(0); | |
| useEffect(() => { | |
| // Only fit bounds if a NEW layer was added (not on every style update) | |
| if (layers.length > prevLayersLength.current) { | |
| const latestLayer = layers[layers.length - 1]; // Latest is at end of array (top layer) | |
| 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; | |
| } | |
| // Popup content for features - Rich display with all properties | |
| function onEachFeature(feature: any, layer: L.Layer) { | |
| if (feature.properties) { | |
| const props = feature.properties; | |
| // Start popup with styled container | |
| let popupContent = ` | |
| <div style="min-width: 200px; font-family: system-ui, sans-serif;"> | |
| `; | |
| // Determine admin level and show appropriate title | |
| 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> | |
| `; | |
| // Admin hierarchy (if not at country level) | |
| 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>`; | |
| } | |
| // Key metrics section | |
| 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>`; | |
| } | |
| // Additional properties (expandable) | |
| 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); | |
| // Prepare reversed layers for visual display (Top layer at top of list) | |
| // We reverse a copy of the ID list to determine visual order | |
| 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); | |
| // Convert visual indices (reversed list) to original indices | |
| // Visual 0 -> Original LAST (length - 1) | |
| 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> | |
| ); | |
| } | |