GerardCB's picture
Deploy to Spaces (Final Clean)
4851501
"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='&copy; <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>
);
}