"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) => void; onLayerRemove: (id: string) => void; onLayerReorder: (fromIndex: number, toIndex: number) => void; } // ============== Choropleth Color Scales ================= const COLOR_PALETTES: Record = { 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) => 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 (
{/* Drag Handle */}
{layer.name}
{/* Layer Settings (Expanded) */} {expandedId === layer.id && (
{/* Fill Settings */}
onUpdate(layer.id, { style: { ...layer.style, fillColor: e.target.value } })} className="w-6 h-6 rounded cursor-pointer border-0 p-0" />
{Math.round((layer.style.fillOpacity ?? 0.6) * 100)}%
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" />
{/* Border Settings */}
{/* Border Color */}
onUpdate(layer.id, { style: { ...layer.style, color: e.target.value } })} className="w-6 h-6 rounded cursor-pointer border-0 p-0" />
{/* Border Width */}
{layer.style.weight ?? 1}px
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" />
{/* Border Opacity (using style.opacity which Leaflet uses for stroke opacity) */}
{Math.round((layer.style.opacity ?? 1) * 100)}%
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" />
)}
); } // 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 = `
`; // 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 += `
${title}
${adminLevel ? `
${adminLevel}
` : ''}
`; // 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 += `
`; hierarchyItems.forEach(item => { popupContent += `
${item}
`; }); popupContent += `
`; } // 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 += `
`; metrics.forEach(m => { popupContent += `
${m.label} ${m.value}
`; }); popupContent += `
`; } // 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 += `
`; additionalProps.slice(0, 5).forEach(([key, value]) => { const cleanKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); popupContent += `
${cleanKey}: ${value}
`; }); popupContent += `
`; } popupContent += `
`; layer.bindPopup(popupContent, { maxWidth: 300 }); } } export default function MapViewer({ layers, onLayerUpdate, onLayerRemove, onLayerReorder }: MapViewerProps) { const [showLegend, setShowLegend] = useState(true); const [expandedLayerId, setExpandedLayerId] = useState(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 (
{/* 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: `
${iconChar}
`, className: 'custom-emoji-marker', iconSize: [size, size], iconAnchor: [size / 2, size / 2], popupAnchor: [0, -size / 2] }); return L.marker(latlng, { icon: divIcon }); }; return ( { // 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} /> ); })}
{/* Layer Control Panel */} {showLegend && layers.length > 0 && (
Layers ({layers.length})
l.id)} strategy={verticalListSortingStrategy} >
{reversedLayers.map((layer) => ( ))}
)} {/* Toggle legend button when hidden */} {!showLegend && layers.length > 0 && ( )} {/* Empty state overlay */} {layers.length === 0 && !dismissedEmptyState && (

No Data Displayed

Ask GeoQuery about population, districts, or coverage to see data on the map.

)}
); }