From c052eebca9be235d312ccd01c250bed7dd202eda Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 22 Mar 2026 17:35:50 +0100 Subject: [PATCH] Maintenance Love :) --- .../src/modules/places/CompetitorsMapView.tsx | 271 +---- .../modules/places/GridSearchPlayground.tsx | 232 +++-- .../places/components/GridSearchMap.tsx | 452 ++++++-- .../places/components/GridSearchSimulator.tsx | 962 ++++++++++++++---- .../modules/places/components/MapFooter.tsx | 119 +++ .../places/components/MapPosterOverlay.tsx | 199 ++++ .../modules/places/components/map-styles.ts | 40 + .../modules/places/gadm-picker/GadmPicker.tsx | 74 +- .../places/hooks/useGridSearchState.ts | 9 + .../modules/places/hooks/useMapControls.ts | 116 +++ .../modules/places/utils/grid-generator.ts | 309 +++++- .../src/modules/places/utils/poster-themes.ts | 273 +++++ 12 files changed, 2392 insertions(+), 664 deletions(-) create mode 100644 packages/ui/src/modules/places/components/MapFooter.tsx create mode 100644 packages/ui/src/modules/places/components/MapPosterOverlay.tsx create mode 100644 packages/ui/src/modules/places/components/map-styles.ts create mode 100644 packages/ui/src/modules/places/hooks/useMapControls.ts create mode 100644 packages/ui/src/modules/places/utils/poster-themes.ts diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index 289441f5..4724459f 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -8,8 +8,12 @@ import { LocationDetailView } from './LocationDetail'; import { InfoPanel } from './InfoPanel'; import { GadmPicker } from './gadm-picker'; import { Info, Sparkles, Crosshair } from 'lucide-react'; +import { useMapControls } from './hooks/useMapControls'; +import { MapFooter } from './components/MapFooter'; +import { MAP_STYLES, type MapStyleKey } from './components/map-styles'; // import { useLocationEnrichment } from './hooks/useEnrichment'; + interface CompetitorsMapViewProps { competitors: CompetitorFull[]; onMapCenterUpdate: (loc: string, zoom?: number) => void; @@ -21,46 +25,6 @@ interface CompetitorsMapViewProps { enrichmentProgress?: { current: number; total: number; message: string } | null; } -const MAP_STYLE_OSM_3D = { - version: 8 as const, - sources: { - osm: { - type: 'raster', - tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - attribution: '© OpenStreetMap Contributors', - maxzoom: 19 - }, - terrainSource: { - type: 'raster-dem', - url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', - tileSize: 256 - }, - hillshadeSource: { - type: 'raster-dem', - url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', - tileSize: 256 - } - }, - layers: [ - { id: 'osm', type: 'raster', source: 'osm' }, - { - id: 'hills', type: 'hillshade', source: 'hillshadeSource', - layout: { visibility: 'visible' as const }, - paint: { 'hillshade-shadow-color': '#473B24' } - } - ], - terrain: { source: 'terrainSource', exaggeration: 1 }, - sky: {} -}; - -const MAP_STYLES = { - light: MAP_STYLE_OSM_3D, - dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', - osm_raster: MAP_STYLE_OSM_3D -}; - - const renderPopupHtml = (competitor: CompetitorFull) => { // Helper to extract image URL safely @@ -135,10 +99,10 @@ export const CompetitorsMapView: React.FC = ({ competit const map = useRef(null); const { theme, systemTheme } = useTheme(); const currentTheme = theme === 'system' ? systemTheme : theme; - const [mapStyle, setMapStyle] = useState(currentTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light); + const [mapStyleKey, setMapStyleKey] = useState(currentTheme === 'dark' ? 'dark' : 'light'); useEffect(() => { - setMapStyle(currentTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light); + setMapStyleKey(currentTheme === 'dark' ? 'dark' : 'light'); }, [currentTheme]); // Selection and Sidebar State @@ -150,10 +114,7 @@ export const CompetitorsMapView: React.FC = ({ competit // Info Panel State const [infoPanelOpen, setInfoPanelOpen] = useState(false); - // State for visual feedback - const [currentCenterLabel, setCurrentCenterLabel] = useState(null); - const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number } | null>(null); - const [isLocating, setIsLocating] = useState(false); + const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(onMapCenterUpdate); // Enrichment Hook - NOW PASSED VIA PROPS // const { enrich, isEnriching, progress: enrichmentProgress } = useLocationEnrichment(); @@ -195,7 +156,7 @@ export const CompetitorsMapView: React.FC = ({ competit map.current = new maplibregl.Map({ container: mapContainer.current, - style: mapStyle, + style: MAP_STYLES[mapStyleKey], center: [initialCenter?.lng ?? 10.45, initialCenter?.lat ?? 51.16], // Default center (Germany roughly) or prop zoom: initialZoom ?? 5, pitch: 0, @@ -223,67 +184,11 @@ export const CompetitorsMapView: React.FC = ({ competit } }); - // Track movements for display and firing external updates - const updateInternals = () => { - if (!map.current) return; - const c = map.current.getCenter(); - const z = map.current.getZoom(); - - setMapInternals({ - zoom: Math.round(z * 100) / 100, - lat: Math.round(c.lat * 10000) / 10000, - lng: Math.round(c.lng * 10000) / 10000 - }); - }; - - const handleMoveEnd = async () => { - if (!map.current) return; - const c = map.current.getCenter(); - const z = map.current.getZoom(); - - if (onMapMoveRef.current) { - onMapMoveRef.current({ - lat: c.lat, - lng: c.lng, - zoom: z - }); - } - - try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3333'; - const res = await fetch(`${apiUrl}/api/regions/reverse?lat=${c.lat}&lon=${c.lng}`); - if (res.ok) { - const json = await res.json(); - if (json.data) { - const geo = json.data; - const parts: string[] = []; - if (geo.city && geo.city !== 'unknown') parts.push(geo.city); - else if (geo.locality && geo.locality !== 'unknown') parts.push(geo.locality); - else if (geo.principalSubdivision && geo.principalSubdivision !== 'unknown') parts.push(geo.principalSubdivision); - if (geo.countryName) parts.push(geo.countryName); - - const label = parts.length > 0 ? parts.join(', ') : null; - setCurrentCenterLabel(label); - if (onMapCenterUpdate && label) { - onMapCenterUpdate(label, z); - } - } - } - } catch (e) { - console.error("Failed to reverse geocode center", e); - } - } - - map.current.on('move', updateInternals); - map.current.on('zoom', updateInternals); - map.current.on('moveend', handleMoveEnd); - - // Initial set - updateInternals(); + const cleanupListeners = setupMapListeners(map.current, onMapMoveRef.current); return () => { - // Clean up the map instance properly on component unmount - // to prevent memory leaks and spurious 'resize'/'moveend' events + cleanupListeners(); + cleanupLocateMarker(); if (map.current) { map.current.remove(); map.current = null; @@ -348,11 +253,11 @@ export const CompetitorsMapView: React.FC = ({ competit // Sync Theme/Style useEffect(() => { if (!map.current) return; - map.current.setStyle(mapStyle); + map.current.setStyle(MAP_STYLES[mapStyleKey]); // Note: Re-adding sources/layers after style switch would be needed here for production resilience, // but for now we assume style switching might reset them. // A robust solution would re-initialize layers on 'style.load'. - }, [mapStyle]); + }, [mapStyleKey]); // Handle Layout Resize useEffect(() => { @@ -403,62 +308,7 @@ export const CompetitorsMapView: React.FC = ({ competit - const userLocationMarkerRef = useRef(null); - const handleZoomToFit = useCallback(() => { - if (!map.current || validLocations.length === 0) return; - - const bounds = new maplibregl.LngLatBounds(); - validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); - - if (!bounds.isEmpty()) { - map.current.fitBounds(bounds, { - padding: 100, - maxZoom: 15 - }); - } - }, [validLocations]); - - const handleLocate = async () => { - if (!map.current) return; - setIsLocating(true); - try { - // Using http://ip-api.com/json/ as requested - // Note: This is HTTP only on free plan, so it relies on the browser/proxy allowing mixed content if served over HTTPS - const res = await fetch('http://ip-api.com/json/'); - if (res.ok) { - const data = await res.json(); - if (data.status === 'success' && data.lat && data.lon) { - - // Remove existing marker - if (userLocationMarkerRef.current) { - userLocationMarkerRef.current.remove(); - } - - // Create new marker - const marker = new maplibregl.Marker({ color: '#ef4444' }) // Red marker for user - .setLngLat([data.lon, data.lat]) - .setPopup(new maplibregl.Popup({ closeButton: false }).setText('Your Location (IP)')) - .addTo(map.current); - - userLocationMarkerRef.current = marker; - - map.current.flyTo({ - center: [data.lon, data.lat], - zoom: 12 - }); - } else { - console.error("IP Geolocation failed or invalid data:", data); - } - } else { - console.error("IP Geolocation fetch failed"); - } - } catch (e) { - console.error("Error during IP geolocation", e); - } finally { - setIsLocating(false); - } - }; return (
@@ -478,11 +328,10 @@ export const CompetitorsMapView: React.FC = ({ competit
- - - - - -
- - -
+ handleLocate(map.current)} + onZoomToFit={() => { + if (!map.current || validLocations.length === 0) return; + const bounds = new maplibregl.LngLatBounds(); + validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); + if (!bounds.isEmpty()) { + map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 }); + } + }} + activeStyleKey={mapStyleKey} + onStyleChange={setMapStyleKey} + > + {enrichmentProgress && (
@@ -580,7 +401,7 @@ export const CompetitorsMapView: React.FC = ({ competit {enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total})
)} -
+ {/* Resizable Handle and Property Pane */} diff --git a/packages/ui/src/modules/places/GridSearchPlayground.tsx b/packages/ui/src/modules/places/GridSearchPlayground.tsx index 094f3e28..c6fdb15b 100644 --- a/packages/ui/src/modules/places/GridSearchPlayground.tsx +++ b/packages/ui/src/modules/places/GridSearchPlayground.tsx @@ -1,97 +1,177 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { GadmPicker } from './gadm-picker'; import { GridSearchMap } from './components/GridSearchMap'; import { GenerateGridForm } from './components/GenerateGridForm'; import { GridSearchSelector } from './components/GridSearchSelector'; import { GridSearchSimulator } from './components/GridSearchSimulator'; +import { MapPosterOverlay } from './components/MapPosterOverlay'; import { useGridSearchState } from './hooks/useGridSearchState'; -import { MousePointerClick } from 'lucide-react'; import CollapsibleSection from '@/components/CollapsibleSection'; export default function GridSearchPlayground() { const state = useGridSearchState(); + const containerRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + const toggleFullscreen = useCallback(() => { + if (!document.fullscreenElement) { + containerRef.current?.requestFullscreen().catch(err => { + console.error(`Error attempting to enable full-screen mode: ${err.message}`); + }); + } else { + document.exitFullscreen(); + } + }, []); return ( -
+
{/* ── Sidebar ────────────────────────────────────────────────── */} -
-

GridSearch Playground

+ {!state.posterMode && ( +
+

GridSearch Playground

- - - - -
- - - - - - -
- state.setGadmPickerActive(false)} - onSelectionChange={(r, polygons) => { - state.setPickerRegions(r); - state.setPickerPolygons(polygons); - }} - /> -
-
- - {state.pickerRegions && state.pickerRegions.length > 0 && ( - - { - // Example external filter implementation hook! - // e.g. return cell.properties.population > 100 - return true; - }} + + - )} -
+ +
+ + + + + + +
+ state.setGadmPickerActive(false)} + onSelectionChange={(r, polygons) => { + state.setPickerRegions(r); + state.setPickerPolygons(polygons); + }} + /> +
+
+ + {state.pickerRegions && state.pickerRegions.length > 0 && ( + + { + // Example external filter implementation hook! + return true; + }} + /> + + )} +
+
+ )} {/* ── Map ────────────────────────────────────────────────────── */} - +
+ { + const areasWithStats = state.searchData?.areas.filter((a: any) => a.stats) || []; + const regionsWithStats = state.pickerRegions?.filter((r: any) => r.stats) || []; + + let totalPop = 0; + let totalArea = 0; + let label = "Total Selection"; + + if (areasWithStats.length > 0) { + label = "Grid Statistics"; + totalPop = areasWithStats.reduce((sum: number, a: any) => sum + (a.stats?.totalPopulation ?? 0), 0); + totalArea = areasWithStats.reduce((sum: number, a: any) => sum + (a.stats?.areaSqKm ?? 0), 0); + } else if (regionsWithStats.length > 0) { + label = "Region Statistics"; + totalPop = regionsWithStats.reduce((sum: number, a: any) => sum + (a.stats?.population ?? 0), 0); + totalArea = regionsWithStats.reduce((sum: number, a: any) => sum + (a.stats?.areaSqkm ?? 0), 0); + } + + if (totalPop === 0 && totalArea === 0) return null; + + return ( +
+

{label}

+
+
+ Population: + {totalPop.toLocaleString()} +
+
+ Area: + {totalArea.toLocaleString(undefined, { maximumFractionDigits: 1 })} km² +
+
+
+ ); + })() : null + } + /> + + {state.posterMode && ( + state.setPosterMode(false)} + /> + )} +
); } diff --git a/packages/ui/src/modules/places/components/GridSearchMap.tsx b/packages/ui/src/modules/places/components/GridSearchMap.tsx index 1733c7f3..bdd5cadc 100644 --- a/packages/ui/src/modules/places/components/GridSearchMap.tsx +++ b/packages/ui/src/modules/places/components/GridSearchMap.tsx @@ -1,45 +1,13 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import maplibregl from 'maplibre-gl'; import { useTheme } from 'next-themes'; -import { Sun, Moon, Users, MousePointerClick } from 'lucide-react'; +import { Users, MousePointerClick, MapPin, Palette } from 'lucide-react'; +import { MAP_STYLES, type MapStyleKey } from './map-styles'; +import { POSTER_THEMES } from '../utils/poster-themes'; +import { MapFooter } from './MapFooter'; +import { useMapControls } from '../hooks/useMapControls'; -const MAP_STYLE_OSM_3D = { - version: 8 as const, - sources: { - osm: { - type: 'raster', - tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - attribution: '© OpenStreetMap Contributors', - maxzoom: 19 - }, - terrainSource: { - type: 'raster-dem', - url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', - tileSize: 256 - }, - hillshadeSource: { - type: 'raster-dem', - url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', - tileSize: 256 - } - }, - layers: [ - { id: 'osm', type: 'raster', source: 'osm' }, - { - id: 'hills', type: 'hillshade', source: 'hillshadeSource', - layout: { visibility: 'visible' as const }, - paint: { 'hillshade-shadow-color': '#473B24' } - } - ], - terrain: { source: 'terrainSource', exaggeration: 1 }, - sky: {} -}; -export const MAP_STYLES = { - light: MAP_STYLE_OSM_3D, - dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', -}; function addMapSources(m: maplibregl.Map, bboxData: any, polyData: any) { if (!m.getSource('grid-bboxes')) @@ -48,11 +16,11 @@ function addMapSources(m: maplibregl.Map, bboxData: any, polyData: any) { m.addSource('grid-polygons', { type: 'geojson', data: polyData }); } -function addMapLayers(m: maplibregl.Map, densityVisible: boolean) { +function addMapLayers(m: maplibregl.Map, densityVisible: boolean, isDarkStyle: boolean) { if (!m.getLayer('polygons-fill')) - m.addLayer({ id: 'polygons-fill', type: 'fill', source: 'grid-polygons', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.3 } }); + m.addLayer({ id: 'polygons-fill', type: 'fill', source: 'grid-polygons', paint: { 'fill-color': isDarkStyle ? '#3b82f6' : '#2563eb', 'fill-opacity': isDarkStyle ? 0.3 : 0.5 } }); if (!m.getLayer('polygons-line')) - m.addLayer({ id: 'polygons-line', type: 'line', source: 'grid-polygons', paint: { 'line-color': '#2563eb', 'line-width': 2 } }); + m.addLayer({ id: 'polygons-line', type: 'line', source: 'grid-polygons', paint: { 'line-color': isDarkStyle ? '#2563eb' : '#1d4ed8', 'line-width': isDarkStyle ? 2 : 3 } }); if (!m.getLayer('bboxes-fill')) m.addLayer({ id: 'bboxes-fill', type: 'fill', source: 'grid-bboxes', paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0 } }); if (!m.getLayer('bboxes-line')) @@ -79,15 +47,24 @@ export interface GridSearchMapProps { polygonsFeatureCollection: any; targetBounds?: maplibregl.LngLatBoundsLike; urlStyle?: string | null; + posterMode?: boolean; + posterTheme?: string; showDensity?: boolean; + showCenters?: boolean; gadmPickerActive?: boolean; + pickerPolygons?: any[]; simulatorData?: any; simulatorPath?: any; simulatorScanner?: any; onStyleChange?: (styleUrl: string) => void; + onPosterMode?: (active: boolean) => void; onToggleDensity?: (visible: boolean) => void; + onToggleCenters?: (visible: boolean) => void; onTogglePicker?: (active: boolean) => void; onMapReady?: (map: maplibregl.Map) => void; + overlayBottomLeft?: React.ReactNode; + isFullscreen?: boolean; + onFullscreenToggle?: () => void; } export function GridSearchMap({ @@ -95,63 +72,160 @@ export function GridSearchMap({ polygonsFeatureCollection, targetBounds, urlStyle, + posterMode = false, + posterTheme = 'terracotta', showDensity = false, + showCenters = false, gadmPickerActive = false, + pickerPolygons, simulatorData, simulatorPath, simulatorScanner, onStyleChange, + onPosterMode, onToggleDensity, + onToggleCenters, onTogglePicker, - onMapReady + onMapReady, + overlayBottomLeft, + isFullscreen, + onFullscreenToggle }: GridSearchMapProps) { const mapContainer = useRef(null); const mapWrapper = useRef(null); const map = useRef(null); const [isMapLoaded, setIsMapLoaded] = useState(false); + const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(); + // Theme / map style const { theme, systemTheme } = useTheme(); const currentTheme = theme === 'system' ? systemTheme : theme; - const [mapStyle, setMapStyleRaw] = useState(() => { - if (urlStyle === 'dark') return MAP_STYLES.dark; - if (urlStyle === 'light') return MAP_STYLES.light; - return currentTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light; + const [mapStyleKey, setMapStyleKeyRaw] = useState(() => { + if (urlStyle === 'dark' || urlStyle === 'light') return urlStyle as MapStyleKey; + return currentTheme === 'dark' ? 'dark' : 'light'; }); - const setMapStyle = useCallback((style: any) => { - setMapStyleRaw(style); - onStyleChange?.(style === MAP_STYLES.dark ? 'dark' : 'light'); + const setMapStyleKey = useCallback((styleKey: MapStyleKey) => { + setMapStyleKeyRaw(styleKey); + onStyleChange?.(styleKey); }, [onStyleChange]); + const mapStyleRef = useRef(mapStyleKey); + useEffect(() => { + mapStyleRef.current = mapStyleKey; + }, [mapStyleKey]); + useEffect(() => { if (urlStyle) return; - setMapStyleRaw(currentTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light); + setMapStyleKeyRaw(currentTheme === 'dark' ? 'dark' : 'light'); }, [currentTheme, urlStyle]); useEffect(() => { - if (map.current && isMapLoaded) map.current.setStyle(mapStyle); - }, [mapStyle, isMapLoaded]); + const m = map.current; + if (!m || !isMapLoaded) return; + + if (!posterMode) { + m.setStyle(MAP_STYLES[mapStyleKey]); + return; + } + + const applyPosterTheme = async () => { + try { + const theme = POSTER_THEMES[posterTheme] || POSTER_THEMES['terracotta']; + const styleUrl = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; + const res = await fetch(styleUrl); + const style = await res.json(); + + style.layers = style.layers.map((layer: any) => { + layer.paint = layer.paint || {}; + if (layer.id === 'background') { + layer.paint = { ...layer.paint, 'background-color': theme.bg }; + } + else if (layer.id === 'water' || layer.id.includes('waterway')) { + layer.paint = { ...layer.paint, 'fill-color': theme.water, 'line-color': theme.water }; + } + else if (layer.id.includes('park') || layer.id.includes('landcover') || layer.id.includes('wood') || layer.id.includes('grass')) { + layer.paint = { ...layer.paint, 'fill-color': theme.parks }; + } + else if (layer.id.includes('highway') || layer.id.includes('road')) { + let color = theme.road_default; + if (layer.id.includes('motorway') || layer.id.includes('trunk')) color = theme.road_motorway; + else if (layer.id.includes('primary')) color = theme.road_primary; + else if (layer.id.includes('secondary')) color = theme.road_secondary; + else if (layer.id.includes('tertiary')) color = theme.road_tertiary; + else if (layer.id.includes('residential') || layer.id.includes('pedestrian')) color = theme.road_residential; + + if (layer.type === 'line') { + layer.paint = { ...layer.paint, 'line-color': color }; + } else if (layer.type === 'polygon' || layer.type === 'fill') { + layer.paint = { ...layer.paint, 'fill-color': color }; + } + } + else if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) { + layer.paint = { ...layer.paint, 'text-color': theme.text, 'text-halo-color': theme.bg, 'text-halo-width': 1 }; + } + else if ((layer.id.includes('boundary') || layer.id.includes('admin')) && layer.type === 'line') { + layer.paint = { ...layer.paint, 'line-color': theme.text }; + } + return layer; + }); + + m.setStyle(style); + } catch (e) { + console.error("Failed to apply poster theme", e); + } + }; + applyPosterTheme(); + }, [mapStyleKey, isMapLoaded, posterMode, posterTheme]); + + useEffect(() => { + const m = map.current; + if (!m || !isMapLoaded) return; + + const isDarkStyle = mapStyleKey === 'dark'; + + if (m.getLayer('simulator-grid-fill')) { + m.setPaintProperty('simulator-grid-fill', 'fill-color', [ + 'match', ['get', 'sim_status'], + 'pending', isDarkStyle ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)', + 'skipped', isDarkStyle ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)', + 'processed', isDarkStyle ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)', + 'rgba(0,0,0,0)' + ]); + m.setPaintProperty('simulator-grid-fill', 'fill-outline-color', isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'); + } + + if (m.getLayer('polygons-fill')) { + m.setPaintProperty('polygons-fill', 'fill-color', isDarkStyle ? '#3b82f6' : '#2563eb'); + m.setPaintProperty('polygons-fill', 'fill-opacity', isDarkStyle ? 0.3 : 0.5); + } + if (m.getLayer('polygons-line')) { + m.setPaintProperty('polygons-line', 'line-color', isDarkStyle ? '#2563eb' : '#1d4ed8'); + m.setPaintProperty('polygons-line', 'line-width', isDarkStyle ? 2 : 3); + } + }, [mapStyleKey, isMapLoaded]); useEffect(() => { if (!mapContainer.current) return; const m = new maplibregl.Map({ container: mapContainer.current, - style: mapStyle, + style: MAP_STYLES[mapStyleKey], center: [10.45, 51.16], zoom: 4, maxPitch: 85, + // @ts-ignore MapLibre underlying canvas options allows preserveDrawingBuffer but TS interface might differ + preserveDrawingBuffer: true, }); map.current = m; m.addControl(new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }), 'top-right'); - m.addControl(new maplibregl.FullscreenControl({ container: mapWrapper.current || undefined }), 'top-right'); m.addControl(new maplibregl.TerrainControl({ source: 'terrainSource', exaggeration: 1 }), 'top-right'); m.on('load', () => { setIsMapLoaded(true); onMapReady?.(m); addMapSources(m, { type: 'FeatureCollection', features: [] }, { type: 'FeatureCollection', features: [] }); - addMapLayers(m, false); + addMapLayers(m, false, mapStyleRef.current === 'dark'); setTimeout(() => m.resize(), 100); }); @@ -162,7 +236,7 @@ export function GridSearchMap({ if (!m.getSource('hillshadeSource')) m.addSource('hillshadeSource', terrainDef); } addMapSources(m, bboxesFeatureCollection || { type: 'FeatureCollection', features: [] }, polygonsFeatureCollection || { type: 'FeatureCollection', features: [] }); - addMapLayers(m, showDensity); + addMapLayers(m, showDensity, mapStyleRef.current === 'dark'); const emptyFc = { type: 'FeatureCollection', features: [] } as any; @@ -181,7 +255,20 @@ export function GridSearchMap({ }); } + // Load Pacman Icons + const pacmanOpenSvg = ``; + const pacmanClosedSvg = ``; + + const imgOpen = new Image(30, 30); + imgOpen.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanOpenSvg); + imgOpen.onload = () => { if (!m.hasImage('pacman-open')) m.addImage('pacman-open', imgOpen); }; + + const imgClosed = new Image(30, 30); + imgClosed.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanClosedSvg); + imgClosed.onload = () => { if (!m.hasImage('pacman-closed')) m.addImage('pacman-closed', imgClosed); }; + // Simulator MapLibre Bindings + const isDarkStyle = mapStyleRef.current === 'dark'; if (!m.getSource('simulator-grid')) { m.addSource('simulator-grid', { type: 'geojson', data: emptyFc }); m.addLayer({ @@ -191,12 +278,12 @@ export function GridSearchMap({ paint: { 'fill-color': [ 'match', ['get', 'sim_status'], - 'pending', 'rgba(150, 150, 150, 0.2)', - 'skipped', 'rgba(239, 68, 68, 0.3)', - 'processed', 'rgba(34, 197, 94, 0.4)', + 'pending', isDarkStyle ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)', + 'skipped', isDarkStyle ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)', + 'processed', isDarkStyle ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)', 'rgba(0,0,0,0)' ], - 'fill-outline-color': 'rgba(150, 150, 150, 0.5)' + 'fill-outline-color': isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)' } }); @@ -206,28 +293,53 @@ export function GridSearchMap({ type: 'line', source: 'simulator-path', paint: { - 'line-color': '#3b82f6', - 'line-width': 3, - 'line-dasharray': [2, 2] + 'line-color': 'rgba(59, 130, 246, 0.4)', + 'line-width': 1.5, + 'line-dasharray': [3, 3] } }); m.addSource('simulator-scanner', { type: 'geojson', data: emptyFc }); m.addLayer({ - id: 'simulator-scanner-point', - type: 'circle', + id: 'simulator-scanner-pacman', + type: 'symbol', source: 'simulator-scanner', + layout: { + 'icon-image': ['coalesce', ['get', 'icon_state'], 'pacman-open'], + 'icon-size': 1.0, + 'icon-rotate': ['-', ['get', 'bearing'], 90], + 'icon-rotation-alignment': 'map', + 'icon-allow-overlap': true, + 'icon-ignore-placement': true + } + }); + } + + if (!m.getSource('ghs-centers')) { + m.addSource('ghs-centers', { type: 'geojson', data: emptyFc }); + m.addLayer({ + id: 'ghs-centers-points', + type: 'circle', + source: 'ghs-centers', paint: { - 'circle-radius': 6, - 'circle-color': '#ef4444', - 'circle-stroke-width': 3, - 'circle-stroke-color': '#ffffff' + 'circle-radius': ['match', ['get', 'type'], 'pop', 5, 'built', 4, 3], + 'circle-color': ['match', ['get', 'type'], 'pop', '#facc15', 'built', '#f87171', '#aaaaaa'], + 'circle-stroke-width': 1, + 'circle-stroke-color': '#000000', + 'circle-opacity': 0.8 } }); } }); - return () => { m.remove(); map.current = null; }; + const cleanupListeners = setupMapListeners(m); + + return () => { + cleanupListeners(); + cleanupLocateMarker(); + m.remove(); + map.current = null; + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Effect to update data @@ -247,6 +359,39 @@ export function GridSearchMap({ if (m.getSource('simulator-scanner')) (m.getSource('simulator-scanner') as maplibregl.GeoJSONSource).setData(simulatorScanner || emptyFc); }, [bboxesFeatureCollection, polygonsFeatureCollection, isMapLoaded, simulatorData, simulatorPath, simulatorScanner]); + useEffect(() => { + if (!map.current || !isMapLoaded) return; + const m = map.current; + const emptyFc = { type: 'FeatureCollection', features: [] } as any; + + if (showCenters && pickerPolygons && pickerPolygons.length > 0) { + const features: any[] = []; + pickerPolygons.forEach(fc => { + if (fc && fc.features) { + fc.features.forEach((f: any) => { + if (f.properties?.ghsBuiltCenter) { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: f.properties.ghsBuiltCenter }, + properties: { type: 'built' } + }); + } + if (f.properties?.ghsPopCenter) { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: f.properties.ghsPopCenter }, + properties: { type: 'pop' } + }); + } + }); + } + }); + (m.getSource('ghs-centers') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features }); + } else { + (m.getSource('ghs-centers') as maplibregl.GeoJSONSource)?.setData(emptyFc); + } + }, [showCenters, pickerPolygons, isMapLoaded]); + // Effect for layer visibility useEffect(() => { if (!map.current || !isMapLoaded) return; @@ -259,54 +404,139 @@ export function GridSearchMap({ m.setLayoutProperty('polygons-line', 'visibility', showDensity ? 'none' : 'visible'); }, [showDensity, isMapLoaded]); + const hasAutoCentered = useRef(false); + // Effect for bounds framing useEffect(() => { - if (!map.current || !isMapLoaded || !targetBounds) return; - try { - map.current.fitBounds(targetBounds, { padding: 50 }); - } catch (e) { - console.error("Failed to fit bounds:", e); + if (!map.current || !isMapLoaded) return; + + if (!hasAutoCentered.current) { + let zoomed = false; + if (pickerPolygons && pickerPolygons.length > 0) { + try { + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; + pickerPolygons.forEach(fc => { + fc?.features?.forEach((f: any) => { + if (f.geometry?.type === 'Polygon') { + f.geometry.coordinates[0].forEach((c: any) => { bounds.extend(c); hasPoints = true; }); + } else if (f.geometry?.type === 'MultiPolygon') { + f.geometry.coordinates.forEach((poly: any) => poly[0].forEach((c: any) => { bounds.extend(c); hasPoints = true; })); + } + }); + }); + if (hasPoints) { + map.current.fitBounds(bounds, { padding: 50, duration: 0, maxZoom: 12 }); + zoomed = true; + hasAutoCentered.current = true; + } + } catch (e) { console.error("Failed to fit initial picker bounds", e) } + } + + if (!zoomed && targetBounds) { + try { + map.current.fitBounds(targetBounds, { padding: 50, duration: 0, maxZoom: 12 }); + hasAutoCentered.current = true; + } catch (e) { + console.error("Failed to fit bounds:", e); + } + } + } else { + if (targetBounds) { + try { + map.current.fitBounds(targetBounds, { padding: 50, maxZoom: 12 }); + } catch (e) { + console.error("Failed to fit bounds:", e); + } + } } - }, [targetBounds, isMapLoaded]); + }, [targetBounds, pickerPolygons, isMapLoaded]); return ( -
-
- - {/* Toolbar */} -
-
- - -
- - -
+
+
+
+ + {overlayBottomLeft && ( +
+ {overlayBottomLeft} +
+ )}
+ + handleLocate(map.current)} + onZoomToFit={() => { + if (map.current) { + let zoomed = false; + if (pickerPolygons && pickerPolygons.length > 0) { + try { + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; + pickerPolygons.forEach(fc => { + fc?.features?.forEach((f: any) => { + if (f.geometry?.type === 'Polygon') { + f.geometry.coordinates[0].forEach((c: any) => { bounds.extend(c); hasPoints = true; }); + } else if (f.geometry?.type === 'MultiPolygon') { + f.geometry.coordinates.forEach((poly: any) => poly[0].forEach((c: any) => { bounds.extend(c); hasPoints = true; })); + } + }); + }); + if (hasPoints) { + map.current.fitBounds(bounds, { padding: 50, maxZoom: 12 }); + zoomed = true; + } + } catch (e) { console.error("Failed to fit picker bounds", e) } + } + + if (!zoomed && targetBounds) { + try { + map.current.fitBounds(targetBounds, { padding: 50, maxZoom: 12 }); + } catch (e) { console.error("Failed to fit zoom targetBounds", e) } + } + } + }} + isFullscreen={isFullscreen} + onFullscreenToggle={onFullscreenToggle} + activeStyleKey={mapStyleKey} + onStyleChange={setMapStyleKey} + > + + + + +
); } diff --git a/packages/ui/src/modules/places/components/GridSearchSimulator.tsx b/packages/ui/src/modules/places/components/GridSearchSimulator.tsx index 45b00e06..cb9e9376 100644 --- a/packages/ui/src/modules/places/components/GridSearchSimulator.tsx +++ b/packages/ui/src/modules/places/components/GridSearchSimulator.tsx @@ -1,8 +1,100 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import * as turf from '@turf/turf'; -import { Play, Pause, FastForward, Square, Calculator, Loader2 } from 'lucide-react'; +import { Loader2, Play, Pause, Square, FastForward, Eye, Bug, Copy, Download, Upload } from 'lucide-react'; import { generateGridSearchCells } from '../utils/grid-generator'; +function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export interface GridSimulatorSettings { + gridMode: 'hex' | 'square' | 'admin' | 'centers'; + pathOrder: 'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'; + groupByRegion: boolean; + cellSize: number; + cellOverlap: number; + centroidOverlap: number; + ghsFilterMode: 'AND' | 'OR'; + maxCellsLimit: number; + maxElevation: number; + minDensity: number; + minGhsPop: number; + minGhsBuilt: number; + enableElevation: boolean; + enableDensity: boolean; + enableGhsPop: boolean; + enableGhsBuilt: boolean; + allowMissingGhs: boolean; + bypassFilters: boolean; +} + +function DeferredNumberInput({ value, onChange, ...props }: any) { + const [local, setLocal] = useState(value); + useEffect(() => { setLocal(value); }, [value]); + + const handleCommit = () => { + const num = Number(local); + if (!isNaN(num) && num !== value) onChange(num); + }; + + return ( + setLocal(e.target.value)} + onBlur={handleCommit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} + /> + ) +} + +function DeferredRangeSlider({ value, onChange, ...props }: any) { + const [local, setLocal] = useState(value); + useEffect(() => { setLocal(value); }, [value]); + + const handleCommit = () => { + const num = Number(local); + if (!isNaN(num) && num !== value) onChange(num); + }; + + return ( + setLocal(Number(e.target.value))} + onMouseUp={handleCommit} + onTouchEnd={handleCommit} + onBlur={handleCommit} + /> + ) +} + interface GridSearchSimulatorProps { pickerRegions: any[]; pickerPolygons: any[]; @@ -20,41 +112,178 @@ export function GridSearchSimulator({ const [gridCells, setGridCells] = useState([]); const [progressIndex, setProgressIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); - const [speed, setSpeed] = useState(1); - const [isPrepared, setIsPrepared] = useState(false); + const [speed, setSpeed] = useState(0.5); const [skippedTotal, setSkippedTotal] = useState(0); // Config - const [gridMode, setGridMode] = useState<'hex' | 'square' | 'admin'>('hex'); - const [pathOrder, setPathOrder] = useState<'zigzag' | 'snake' | 'spiral-out' | 'spiral-in'>('snake'); - const [groupByRegion, setGroupByRegion] = useState(true); - const [cellSize, setCellSize] = useState(2.5); - const [cellOverlap, setCellOverlap] = useState(0); - const [maxCellsLimit, setMaxCellsLimit] = useState(15000); - const [maxElevation, setMaxElevation] = useState(700); - const [minDensity, setMinDensity] = useState(10); + const [gridMode, setGridMode] = useLocalStorage<'hex' | 'square' | 'admin' | 'centers'>('pm_gridMode', 'hex'); + const [pathOrder, setPathOrder] = useLocalStorage<'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'>('pm_pathOrder', 'snake'); + const [groupByRegion, setGroupByRegion] = useLocalStorage('pm_groupByRegion', true); + const [cellSize, setCellSize] = useLocalStorage('pm_cellSize', 2.5); + const [cellOverlap, setCellOverlap] = useLocalStorage('pm_cellOverlap', 0); + const [centroidOverlap, setCentroidOverlap] = useLocalStorage('pm_centroidOverlap', 50); + const [ghsFilterMode, setGhsFilterMode] = useLocalStorage<'AND' | 'OR'>('pm_ghsFilterMode', 'AND'); + const [maxCellsLimit, setMaxCellsLimit] = useLocalStorage('pm_maxCellsLimit', 15000); + const [maxElevation, setMaxElevation] = useLocalStorage('pm_maxElevation', 700); + const [minDensity, setMinDensity] = useLocalStorage('pm_minDensity', 10); + const [minGhsPop, setMinGhsPop] = useLocalStorage('pm_minGhsPop', 0); + const [minGhsBuilt, setMinGhsBuilt] = useLocalStorage('pm_minGhsBuilt', 0); + const [enableElevation, setEnableElevation] = useLocalStorage('pm_enElev', false); + const [enableDensity, setEnableDensity] = useLocalStorage('pm_enDens', false); + const [enableGhsPop, setEnableGhsPop] = useLocalStorage('pm_enPop', false); + const [enableGhsBuilt, setEnableGhsBuilt] = useLocalStorage('pm_enBuilt', false); + const [allowMissingGhs, setAllowMissingGhs] = useLocalStorage('pm_allowMissGhs', false); + const [bypassFilters, setBypassFilters] = useLocalStorage('pm_bypassFilters', false); const [isCalculating, setIsCalculating] = useState(false); + const [calcStats, setCalcStats] = useState({ current: 0, total: 0, valid: 0 }); + const skippedCellsRef = useRef([]); + + const fileInputRef = useRef(null); + + const stopRequestedRef = useRef(false); const reqRef = useRef(); const lastTickRef = useRef(0); + const globalProcessedHopsRef = useRef([]); + + const [ghsBounds, setGhsBounds] = useState({ minPop: 0, maxPop: 1000000, minBuilt: 0, maxBuilt: 1000000 }); - // Auto-adjust default cell size based on selected GADM level useEffect(() => { - if (!pickerRegions || pickerRegions.length === 0) return; - const minLvl = Math.min(...pickerRegions.map(r => r.level)); - let defaultSize = 2.5; - if (minLvl === 0) defaultSize = 50; - else if (minLvl === 1) defaultSize = 25; - else if (minLvl === 2) defaultSize = 10; - else if (minLvl === 3) defaultSize = 5; - else if (minLvl === 4) defaultSize = 2.5; - else defaultSize = 1; + if (!pickerPolygons || pickerPolygons.length === 0) return; + let minPop = Infinity; + let maxPop = -Infinity; + let minBuilt = Infinity; + let maxBuilt = -Infinity; - setCellSize(defaultSize); - }, [pickerRegions]); + pickerPolygons.forEach(fc => { + if (fc && fc.features) { + fc.features.forEach((f: any) => { + const raw = f.properties || {}; + const pop = raw.ghsPopulation; + const built = raw.ghsBuiltWeight; + if (typeof pop === 'number') { + if (pop < minPop) minPop = pop; + if (pop > maxPop) maxPop = pop; + } + if (typeof built === 'number') { + if (built < minBuilt) minBuilt = built; + if (built > maxBuilt) maxBuilt = built; + } + }); + } + }); - const handlePrepare = useCallback(() => { - if (!pickerRegions || pickerRegions.length === 0 || !pickerPolygons) return; + if (minPop !== Infinity) { + setGhsBounds({ + minPop, maxPop, minBuilt: minBuilt === Infinity ? 0 : minBuilt, maxBuilt: maxBuilt === -Infinity ? 1000000 : maxBuilt + }); + } + }, [pickerPolygons]); + + // Removed auto-adjust cell size so user preference is preserved across sessions + + // --- IMPORT / EXPORT / COPY SETTINGS --- + const getFinalHopList = () => { + return gridCells + .filter(c => c.properties.sim_status !== 'skipped') + .map((c, i) => { + const pt = turf.centroid(c).geometry.coordinates; + return { + step: i + 1, + lng: Number(pt[0].toFixed(6)), + lat: Number(pt[1].toFixed(6)), + radius_km: c.properties.search_radius_km ? Number(c.properties.search_radius_km.toFixed(2)) : undefined + }; + }); + }; + + const handleCopyWaypoints = async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(getFinalHopList(), null, 2)); + } catch (err) { + console.error('Failed to copy waypoints', err); + } + }; + + const handleExportWaypoints = () => { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(getFinalHopList(), null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", `grid-waypoints-${new Date().toISOString().slice(0, 10)}.json`); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }; + + const getCurrentSettings = (): GridSimulatorSettings => ({ + gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap, + ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt, + enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters + }); + + const handleCopySettings = async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(getCurrentSettings(), null, 2)); + } catch (err) { + console.error('Failed to copy settings', err); + } + }; + + const handleExportSettings = () => { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(getCurrentSettings(), null, 2)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", `grid-simulator-settings-${new Date().toISOString().slice(0, 10)}.json`); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }; + + const handleImportSettings = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const json = JSON.parse(event.target?.result as string) as Partial; + if (json.gridMode) setGridMode(json.gridMode); + if (json.pathOrder) setPathOrder(json.pathOrder); + if (json.groupByRegion !== undefined) setGroupByRegion(json.groupByRegion); + if (json.cellSize !== undefined) setCellSize(json.cellSize); + if (json.cellOverlap !== undefined) setCellOverlap(json.cellOverlap); + if (json.centroidOverlap !== undefined) setCentroidOverlap(json.centroidOverlap); + if (json.ghsFilterMode) setGhsFilterMode(json.ghsFilterMode); + if (json.maxCellsLimit !== undefined) setMaxCellsLimit(json.maxCellsLimit); + if (json.maxElevation !== undefined) setMaxElevation(json.maxElevation); + if (json.minDensity !== undefined) setMinDensity(json.minDensity); + if (json.minGhsPop !== undefined) setMinGhsPop(json.minGhsPop); + if (json.minGhsBuilt !== undefined) setMinGhsBuilt(json.minGhsBuilt); + if (json.enableElevation !== undefined) setEnableElevation(json.enableElevation); + if (json.enableDensity !== undefined) setEnableDensity(json.enableDensity); + if (json.enableGhsPop !== undefined) setEnableGhsPop(json.enableGhsPop); + if (json.enableGhsBuilt !== undefined) setEnableGhsBuilt(json.enableGhsBuilt); + if (json.allowMissingGhs !== undefined) setAllowMissingGhs(json.allowMissingGhs); + if (json.bypassFilters !== undefined) setBypassFilters(json.bypassFilters); + + if (fileInputRef.current) fileInputRef.current.value = ''; + } catch (err) { + console.error("Failed to parse settings JSON", err); + alert("Failed to parse settings JSON. Is it a valid file?"); + } + }; + reader.readAsText(file); + }; + + // --- GRID GENERATION --- + const computeGrid = useCallback(async (autoPlay: boolean | 'preview' = false) => { + if (!pickerRegions || pickerRegions.length === 0 || !pickerPolygons) { + setGridCells([]); + setSimulatorData(turf.featureCollection([])); + setSimulatorPath(turf.featureCollection([])); + setSimulatorScanner(turf.featureCollection([])); + return; + } // 1. Combine regions into a single Multipolygon FeatureCollection const features: any[] = []; @@ -66,51 +295,130 @@ export function GridSearchSimulator({ if (features.length === 0) return; setIsCalculating(true); + stopRequestedRef.current = false; + setCalcStats({ current: 0, total: 0, valid: 0 }); - // Run in next tick so the UI updates to show the loading state - setTimeout(() => { - try { - const { validCells, skippedCells, error } = generateGridSearchCells({ - features, gridMode, cellSize, maxElevation, - minDensity, pathOrder, groupByRegion, onFilterCell, - cellOverlap: cellOverlap / 100, // convert percentage to scalar - maxCellsLimit - }); + // Yield to the event loop so React can render the 'Calculating...' overlay and Stop button + // before the heavy synchronous Turf operations begin. + await new Promise(r => setTimeout(r, 10)); - if (error) { - alert(error); - setIsCalculating(false); - return; + try { + const result = await generateGridSearchCells({ + features, gridMode, cellSize, pathOrder, groupByRegion, onFilterCell, + maxElevation: enableElevation ? maxElevation : 0, + minDensity: enableDensity ? minDensity : 0, + minGhsPop: enableGhsPop ? minGhsPop : 0, + minGhsBuilt: enableGhsBuilt ? minGhsBuilt : 0, + allowMissingGhs, + bypassFilters, + cellOverlap: cellOverlap / 100, // convert percentage to scalar + centroidOverlap: centroidOverlap / 100, + ghsFilterMode, + maxCellsLimit, + skipPolygons: globalProcessedHopsRef.current + }, async (stats) => { + setCalcStats({ current: stats.current, total: stats.total, valid: stats.validCells.length }); + + if (stopRequestedRef.current) { + return false; } + + // Yield to event loop to allow UI updates + await new Promise(r => setTimeout(r, 0)); + return true; + }); - setGridCells(validCells); - setSkippedTotal(skippedCells ? skippedCells.length : 0); - setIsPrepared(true); - setProgressIndex(0); - setIsPlaying(false); - - // Update Map Layers - setSimulatorData(turf.featureCollection(validCells)); - setSimulatorPath(turf.featureCollection([])); - setSimulatorScanner(turf.featureCollection([])); - } catch (err) { - console.error("Turf Error:", err); - alert("An error occurred during grid generation."); - } finally { + if (result.error) { + alert(result.error); setIsCalculating(false); + return; } - }, 50); - }, [pickerRegions, pickerPolygons, gridMode, pathOrder, groupByRegion, cellSize, maxElevation, minDensity, onFilterCell, setSimulatorData, setSimulatorPath, setSimulatorScanner]); + setGridCells(result.validCells); + skippedCellsRef.current = result.skippedCells || []; + setSkippedTotal(result.skippedCells ? result.skippedCells.length : 0); + + if (autoPlay === 'preview') { + setProgressIndex(result.validCells.length); + setIsPlaying(false); + } else { + setProgressIndex(0); + setIsPlaying(stopRequestedRef.current ? false : autoPlay); + } + + // Update Map Layers + setSimulatorData(turf.featureCollection(result.validCells)); + setSimulatorPath(turf.featureCollection([])); + setSimulatorScanner(turf.featureCollection([])); + + setIsCalculating(false); + } catch (err) { + console.error("Turf Error:", err); + alert("An error occurred during grid generation."); + setIsCalculating(false); + } + }, [pickerRegions, pickerPolygons, gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, onFilterCell, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters]); + + const activeSettingsRef = useRef({ gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters }); + + useEffect(() => { + const prev = activeSettingsRef.current; + let changed = false; + + if (prev.gridMode !== gridMode) changed = true; + if (prev.cellSize !== cellSize) changed = true; + if (prev.cellOverlap !== cellOverlap) changed = true; + if (prev.centroidOverlap !== centroidOverlap) changed = true; + if (prev.maxElevation !== maxElevation) changed = true; + if (prev.minDensity !== minDensity) changed = true; + if (prev.minGhsPop !== minGhsPop) changed = true; + if (prev.minGhsBuilt !== minGhsBuilt) changed = true; + if (prev.ghsFilterMode !== ghsFilterMode) changed = true; + if (prev.pathOrder !== pathOrder) changed = true; + if (prev.groupByRegion !== groupByRegion) changed = true; + if (prev.maxCellsLimit !== maxCellsLimit) changed = true; + if (prev.enableElevation !== enableElevation) changed = true; + if (prev.enableDensity !== enableDensity) changed = true; + if (prev.enableGhsPop !== enableGhsPop) changed = true; + if (prev.enableGhsBuilt !== enableGhsBuilt) changed = true; + if (prev.allowMissingGhs !== allowMissingGhs) changed = true; + if (prev.bypassFilters !== bypassFilters) changed = true; + + if (changed) { + globalProcessedHopsRef.current = []; + activeSettingsRef.current = { gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters }; + + // Auto preview if we have polygons and settings actually changed + if (pickerPolygons && pickerPolygons.length > 0) { + // Wrap in setTimeout to avoid React warnings about rendering while updating another component + setTimeout(() => computeGrid('preview'), 0); + } + } + }, [gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, pickerPolygons, computeGrid, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters]); + + + const prevRegionsRef = useRef(''); + useEffect(() => { + const currentRegions = (pickerRegions || []).map(r => r.gid).sort().join(','); + if (prevRegionsRef.current !== currentRegions) { + prevRegionsRef.current = currentRegions; + setGridCells([]); + setIsPlaying(false); + setProgressIndex(0); + setSimulatorData(turf.featureCollection([])); + setSimulatorPath(turf.featureCollection([])); + setSimulatorScanner(turf.featureCollection([])); + } + }, [pickerRegions, setSimulatorData, setSimulatorPath, setSimulatorScanner]); const handleClear = useCallback(() => { setGridCells([]); - setIsPrepared(false); setIsPlaying(false); setProgressIndex(0); setSimulatorData(turf.featureCollection([])); setSimulatorPath(turf.featureCollection([])); setSimulatorScanner(turf.featureCollection([])); + globalProcessedHopsRef.current = []; }, [setSimulatorData, setSimulatorPath, setSimulatorScanner]); // Animation Loop @@ -124,6 +432,12 @@ export function GridSearchSimulator({ if (time - lastTickRef.current > (1000 / (10 * speed))) { // speed 1 = 10 cells/sec setProgressIndex(prev => { + if (prev < gridCells.length) { + const cell = gridCells[prev]; + if (cell.properties.sim_status !== 'skipped') { + globalProcessedHopsRef.current.push(cell); + } + } const next = prev + 1; return next > gridCells.length ? gridCells.length : next; }); @@ -173,7 +487,20 @@ export function GridSearchSimulator({ if (currentIndex > 0 && currentIndex <= activeCells.length) { const currentCell = activeCells[currentIndex - 1]; - setSimulatorScanner(turf.featureCollection([turf.centroid(currentCell)])); + const centroid = turf.centroid(currentCell); + let angle = 0; + if (currentIndex > 1) { + const prevCell = activeCells[currentIndex - 2]; + angle = turf.bearing(turf.centroid(prevCell), centroid); + } else if (currentIndex === 1 && activeCells.length > 1) { + const nextCell = activeCells[1]; + angle = turf.bearing(centroid, turf.centroid(nextCell)); + } + centroid.properties = { + bearing: angle, + icon_state: (progressIndex % 2 === 0) ? 'pacman-open' : 'pacman-closed' + }; + setSimulatorScanner(turf.featureCollection([centroid])); } else { setSimulatorScanner(turf.featureCollection([])); } @@ -187,172 +514,79 @@ export function GridSearchSimulator({ if (!pickerRegions || pickerRegions.length === 0) return null; return ( -
- {!isPrepared ? ( -
-

- Select a GADM region and prepare the Turf.js grid simulator to calculate precise API costs and coverage. -

- -
-
- -
- - - -
-
- -
- -
- - - - -
- - - -
-
- - setMaxElevation(Number(e.target.value))} - /> - Drop > {maxElevation}m -
-
- - setMinDensity(Number(e.target.value))} - /> - Drop < {minDensity} -
-
-
- - {gridMode !== 'admin' && ( -
-
-
- - setCellSize(Number(e.target.value))} - min={0.5} - step={0.5} - /> -
-
- - setCellOverlap(Number(e.target.value))} - min={0} - max={100} - step={1} - title="Increase to balloon the geometric cells and ensure no places are missed at the seams." - /> -
-
-
- - setMaxCellsLimit(Number(e.target.value))} - step={500} - /> -
-
- )} -
- - -
- ) : ( -
- {/* Controls */} -
-
- - + +
- {[1, 5, 10, 50].map(s => ( + {[0.5, 1, 5, 10, 50].map(s => ( @@ -370,22 +604,310 @@ export function GridSearchSimulator({
-
-
- Target Calls - {validCount} +
+
+
+ Target Calls + {validCount} +
+ {gridCells.length > 0 && ( +
+ + +
+ )}
-
+
Skipped Calls {skippedCount}
-
+
Simulated Processed {processedCount}
+ +
+
+

+ Settings +

+
+ + + + +
+
+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + + +
+ + + +
+
+ +
+ setMaxElevation(num)} + /> + Drop > {maxElevation}m +
+
+
+ +
+ setMinDensity(num)} + /> + Drop < {minDensity} +
+
+ +
+
+

GHS Data Thresholds

+

Logic when filters are active.

+
+
+ + +
setGhsFilterMode(ghsFilterMode === 'AND' ? 'OR' : 'AND')}> + AND + + OR +
+
+
+ +
+ +
+ {ghsBounds.minPop > 0 ? ghsBounds.minPop.toLocaleString() : '0'} + setMinGhsPop(num)} + className="flex-1" + /> + {ghsBounds.maxPop > 0 ? ghsBounds.maxPop.toLocaleString() : 'Max'} +
+
+
+ +
+ {ghsBounds.minBuilt > 0 ? ghsBounds.minBuilt.toLocaleString() : '0'} + setMinGhsBuilt(num)} + className="flex-1" + /> + {ghsBounds.maxBuilt > 0 ? ghsBounds.maxBuilt.toLocaleString() : 'Max'} +
+
+
+
+ +
+
+
+ + setCellSize(num)} + min={0.5} + step={0.5} + disabled={gridMode === 'admin'} + /> +
+ {gridMode === 'centers' ? ( +
+ + setCentroidOverlap(num)} + min={0} + max={100} + step={1} + title="0% means centroids must be at least 1 cell-size apart. 100% allows exact duplicates." + /> +
+ No Overlap (Dist) + Full Overlap +
+
+ ) : ( +
+ + setCellOverlap(num)} + min={0} + max={100} + step={1} + title="Increase to balloon the geometric cells and ensure no places are missed at the seams." + disabled={gridMode === 'admin'} + /> +
+ )} +
+
+ + setMaxCellsLimit(num)} + step={500} + disabled={gridMode === 'admin'} + /> +
+
+
+
+ + {isCalculating && ( +
+
+
+ + + Calculating... + {calcStats.total > 0 && {calcStats.current} / {calcStats.total} cells} + +
+ +
+
)}
); diff --git a/packages/ui/src/modules/places/components/MapFooter.tsx b/packages/ui/src/modules/places/components/MapFooter.tsx new file mode 100644 index 00000000..7bbf0e22 --- /dev/null +++ b/packages/ui/src/modules/places/components/MapFooter.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Map as MapIcon, Loader2, Locate, Maximize, Minimize, Focus, Sun, Moon } from 'lucide-react'; +import type { MapStyleKey } from './map-styles'; + +export interface MapFooterProps { + currentCenterLabel?: string | null; + mapInternals?: { lat: number; lng: number; zoom: number } | null; + isLocating?: boolean; + onLocate?: () => void; + onZoomToFit?: () => void; + isFullscreen?: boolean; + onFullscreenToggle?: () => void; + activeStyleKey?: MapStyleKey; + onStyleChange?: (styleKey: MapStyleKey) => void; + children?: React.ReactNode; +} + +export function MapFooter({ + currentCenterLabel, + mapInternals, + isLocating = false, + onLocate, + onZoomToFit, + isFullscreen = false, + onFullscreenToggle, + activeStyleKey, + onStyleChange, + children +}: MapFooterProps) { + return ( +
+
+ {currentCenterLabel ? ( +
+ + {currentCenterLabel} +
+ ) : ( + Pan map to view details + )} + + {mapInternals && ( +
+ Lat: {mapInternals.lat.toFixed(4)} + Lng: {mapInternals.lng.toFixed(4)} + Zoom: {mapInternals.zoom.toFixed(1)} +
+ )} +
+ + {/* Style Toggles & Actions */} +
+ {onLocate && ( + + )} + + {onZoomToFit && ( + + )} + + {onFullscreenToggle && ( + + )} + + {onStyleChange && ( + <> + + + + + )} + + {children && ( + <> +
+ {children} + + )} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/components/MapPosterOverlay.tsx b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx new file mode 100644 index 00000000..1e89d04e --- /dev/null +++ b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import { POSTER_THEMES } from '../utils/poster-themes'; + +// Fallback feather icons if lucide-react unavailable, or just raw SVG +const XIcon = ({ className }: { className?: string }) => ( + +); +const PaletteIcon = ({ className }: { className?: string }) => ( + +); + +interface MapPosterOverlayProps { + map: maplibregl.Map | null; + pickerRegions: any[]; + pickerPolygons?: any[]; + posterTheme: string; + setPosterTheme: (theme: string) => void; + onClose: () => void; +} + +const MapIcon = ({ className }: { className?: string }) => ( + +); + +export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterTheme, setPosterTheme, onClose }: MapPosterOverlayProps) { + const theme = POSTER_THEMES[posterTheme] || POSTER_THEMES['terracotta']; + const [center, setCenter] = useState<{ lat: number, lng: number } | null>(null); + + const [inferredCity, setInferredCity] = useState("CITY NAME"); + const [inferredCountry, setInferredCountry] = useState("COUNTRY"); + + useEffect(() => { + if (!map) return; + const updateCenter = () => { + const c = map.getCenter(); + setCenter({ lat: c.lat, lng: c.lng }); + }; + + const fetchGeo = async () => { + const c = map.getCenter(); + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3333'; + const res = await fetch(`${apiUrl}/api/regions/reverse?lat=${c.lat}&lon=${c.lng}`); + if (res.ok) { + const json = await res.json(); + if (json.data) { + const geo = json.data; + let cty = geo.city && geo.city !== 'unknown' ? geo.city : + geo.locality && geo.locality !== 'unknown' ? geo.locality : + geo.principalSubdivision && geo.principalSubdivision !== 'unknown' ? geo.principalSubdivision : null; + let ctry = geo.countryName || null; + if (cty && cty !== "unknown") setInferredCity(cty); + if (ctry && ctry !== "unknown") setInferredCountry(ctry); + } + } + } catch (e) {} + }; + + updateCenter(); + fetchGeo(); + + map.on('move', updateCenter); + map.on('moveend', fetchGeo); + + return () => { + map.off('move', updateCenter); + map.off('moveend', fetchGeo); + } + }, [map]); + + let city = "CITY NAME"; + let country = "COUNTRY"; + + if (pickerRegions && pickerRegions.length > 0) { + const countryRegion = pickerRegions.find(r => r.level === 0); + const cityRegion = pickerRegions.slice().reverse().find(r => r.level > 0); + if (countryRegion && countryRegion.name) country = countryRegion.name; + if (cityRegion && cityRegion.name) city = cityRegion.name; + else if (countryRegion && countryRegion.name) city = countryRegion.name; + } + + city = (city === "CITY NAME" || !city) ? inferredCity : city; + country = (country === "COUNTRY" || !country) ? inferredCountry : country; + + const formatCoords = (c: { lat: number, lng: number }) => { + const latStr = `${Math.abs(c.lat).toFixed(4)}° ${c.lat >= 0 ? 'N' : 'S'}`; + const lngStr = `${Math.abs(c.lng).toFixed(4)}° ${c.lng >= 0 ? 'E' : 'W'}`; + return `${latStr} / ${lngStr}`; + }; + + const displayCity = city.toUpperCase().split('').join(' '); + + const gradientTop = `linear-gradient(to bottom, ${theme.gradient_color}FF 0%, ${theme.gradient_color}00 100%)`; + const gradientBottom = `linear-gradient(to top, ${theme.gradient_color}FF 30%, ${theme.gradient_color}00 100%)`; + + const [showGadmBorders, setShowGadmBorders] = useState(true); + + useEffect(() => { + if (!map || !pickerPolygons || pickerPolygons.length === 0) return; + + const combinedFeatures = pickerPolygons.flatMap(fc => fc.features || []); + const fc = { type: 'FeatureCollection', features: combinedFeatures }; + + if (!map.getSource('poster-gadm')) { + map.addSource('poster-gadm', { type: 'geojson', data: fc as any }); + map.addLayer({ + id: 'poster-gadm-borders', + type: 'line', + source: 'poster-gadm', + paint: { + 'line-color': theme.text, + 'line-width': 1.5, + 'line-dasharray': [4, 4] + } + }); + } + + if (showGadmBorders) { + map.setLayoutProperty('poster-gadm-borders', 'visibility', 'visible'); + map.setPaintProperty('poster-gadm-borders', 'line-color', theme.text); + } else { + map.setLayoutProperty('poster-gadm-borders', 'visibility', 'none'); + } + + return () => { + if (map.getLayer('poster-gadm-borders')) map.removeLayer('poster-gadm-borders'); + if (map.getSource('poster-gadm')) map.removeSource('poster-gadm'); + }; + }, [map, pickerPolygons, showGadmBorders, theme.text]); + + return ( +
+ {/* Top Gradient */} +
+ + {/* Controls (pointer events auto) */} +
+
+ + +
+ {pickerPolygons && pickerPolygons.length > 0 && ( + + )} + +
+ + {/* Bottom Content Area */} +
+
+
+ {displayCity} +
+ +
+ +
+ {country.toUpperCase()} +
+ +
+ {center ? formatCoords(center) : 'Loading...'} +
+
+ + {/* Attribution */} +
+ © OpenStreetMap contributors +
+
+
+ ); +} diff --git a/packages/ui/src/modules/places/components/map-styles.ts b/packages/ui/src/modules/places/components/map-styles.ts new file mode 100644 index 00000000..08cbac48 --- /dev/null +++ b/packages/ui/src/modules/places/components/map-styles.ts @@ -0,0 +1,40 @@ +export const MAP_STYLE_OSM_3D = { + version: 8 as const, + sources: { + osm: { + type: 'raster' as const, + tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap Contributors', + maxzoom: 19 + }, + terrainSource: { + type: 'raster-dem' as const, + url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', + tileSize: 256 + }, + hillshadeSource: { + type: 'raster-dem' as const, + url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', + tileSize: 256 + } + }, + layers: [ + { id: 'osm', type: 'raster' as const, source: 'osm' }, + { + id: 'hills', type: 'hillshade' as const, source: 'hillshadeSource', + layout: { visibility: 'visible' as const }, + paint: { 'hillshade-shadow-color': '#473B24' } + } + ], + terrain: { source: 'terrainSource', exaggeration: 1 }, + sky: {} +}; + +export const MAP_STYLES = { + light: MAP_STYLE_OSM_3D, + dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', + osm_raster: MAP_STYLE_OSM_3D +}; + +export type MapStyleKey = keyof typeof MAP_STYLES; diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index ff1d3fcb..8714525b 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -1,10 +1,35 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import maplibregl from 'maplibre-gl'; import { Loader2, X, MapPin, Crosshair, Plus, ChevronRight, Copy, Download, Upload, Trash2 } from 'lucide-react'; +import * as turf from '@turf/turf'; import { searchGadmRegions, fetchRegionHierarchy, fetchRegionBoundary } from '../client-searches'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; +function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + export interface GadmRegion { gid: string; gadmName: string; @@ -73,9 +98,9 @@ const LEVEL_OPTIONS = [ ]; export function GadmPicker({ map, active, onClose, onSelectionChange, className = "" }: GadmPickerProps) { - const [levelOption, setLevelOption] = useState(0); - const [resolutionOption, setResolutionOption] = useState(1); - const [enrich, setEnrich] = useState(false); + const [levelOption, setLevelOption] = useLocalStorage('pm_gadm_levelOption', 0); + const [resolutionOption, setResolutionOption] = useLocalStorage('pm_gadm_resolutionOption', 1); + const [enrich, setEnrich] = useLocalStorage('pm_gadm_enrich', true); // UI state const [query, setQuery] = useState(''); @@ -110,12 +135,42 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className stateRef.current = { levelOption, resolutionOption, enrich }; }, [levelOption, resolutionOption, enrich]); + // Restore selected Gadm regions on mount + useEffect(() => { + let mounted = true; + try { + const saved = window.localStorage.getItem('pm_gadm_saved_regions'); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length > 0) { + setTimeout(() => { + for (const item of parsed) { + if (!mounted) break; + const raw = item.raw || { gid: item.gid, gadmName: item.name, level: item.level }; + handleSelectRegion(raw, undefined, true); + } + }, 50); + } + } + } catch(e) { console.error('Failed to parse cached gadm regions', e); } + return () => { mounted = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Provide selected data mapping up useEffect(() => { if (onSelectionChange) { const polygons = selectedRegions.map(r => geojsons[r.gid]).filter(Boolean); onSelectionChange(selectedRegions, polygons); } + + // Persist to local storage + if (selectedRegions.length > 0) { + const data = selectedRegions.map(r => ({ gid: r.gid, name: r.gadmName, level: r.level, raw: r.raw })); + window.localStorage.setItem('pm_gadm_saved_regions', JSON.stringify(data)); + } else { + window.localStorage.removeItem('pm_gadm_saved_regions'); + } }, [selectedRegions, geojsons]); const updateMapFeatures = useCallback(() => { @@ -382,8 +437,11 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className let activeStats: Record | undefined; if (enrich && geojson.features && geojson.features.length > 0) { const props = geojson.features[0].properties; - if (props.areaSqkm !== undefined || props.population !== undefined) { - activeStats = { areaSqkm: props.areaSqkm, population: props.population }; + if (props.ghsPopulation !== undefined || props.ghsBuiltWeight !== undefined) { + activeStats = { + ghsBuiltWeight: props.ghsBuiltWeight, + ghsPopulation: props.ghsPopulation + }; } } @@ -524,7 +582,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className className="data-[state=checked]:bg-sky-500" />
@@ -666,8 +724,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
{region.stats && !loading && (
- Area: {region.stats.areaSqkm?.toLocaleString() ?? 'N/A'} km² - Pop: {region.stats.population?.toLocaleString() ?? 'N/A'} + Built: {region.stats.ghsBuiltWeight ? Math.round(region.stats.ghsBuiltWeight).toLocaleString() : 'N/A'} + Pop: {region.stats.ghsPopulation ? Math.round(region.stats.ghsPopulation).toLocaleString() : 'N/A'}
)}
diff --git a/packages/ui/src/modules/places/hooks/useGridSearchState.ts b/packages/ui/src/modules/places/hooks/useGridSearchState.ts index a2e233ad..aa8c4c20 100644 --- a/packages/ui/src/modules/places/hooks/useGridSearchState.ts +++ b/packages/ui/src/modules/places/hooks/useGridSearchState.ts @@ -47,13 +47,19 @@ export function useGridSearchState() { const includeStats = searchParams.get('stats') !== '0'; const showDensity = searchParams.get('density') === '1'; + const showCenters = searchParams.get('centers') === '1'; const gadmPickerActive = searchParams.get('picker') === '1'; const urlStyle = searchParams.get('style'); + const posterMode = searchParams.get('poster') === '1'; + const posterTheme = searchParams.get('theme') || 'terracotta'; const setIncludeStats = useCallback((val: boolean) => updateParam('stats', val ? null : '0'), [updateParam]); const setShowDensity = useCallback((val: boolean) => updateParam('density', val ? '1' : null), [updateParam]); + const setShowCenters = useCallback((val: boolean) => updateParam('centers', val ? '1' : null), [updateParam]); const setGadmPickerActive = useCallback((val: boolean) => updateParam('picker', val ? '1' : null), [updateParam]); const setUrlStyle = useCallback((style: string) => updateParam('style', style), [updateParam]); + const setPosterMode = useCallback((val: boolean) => updateParam('poster', val ? '1' : null), [updateParam]); + const setPosterTheme = useCallback((theme: string) => updateParam('theme', theme), [updateParam]); const [pickerRegions, setPickerRegions] = useState([]); const [pickerPolygons, setPickerPolygons] = useState([]); @@ -165,7 +171,10 @@ export function useGridSearchState() { mapInstance, setMapInstance, bboxesData, polygonsData, targetBounds, urlStyle, setUrlStyle, + posterMode, setPosterMode, + posterTheme, setPosterTheme, showDensity, setShowDensity, + showCenters, setShowCenters, gadmPickerActive, setGadmPickerActive, includeStats, setIncludeStats, searches, selectedSearch, setSelectedSearch, diff --git a/packages/ui/src/modules/places/hooks/useMapControls.ts b/packages/ui/src/modules/places/hooks/useMapControls.ts new file mode 100644 index 00000000..5c50f927 --- /dev/null +++ b/packages/ui/src/modules/places/hooks/useMapControls.ts @@ -0,0 +1,116 @@ +import { useState, useCallback, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; + +export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) => void) { + const [currentCenterLabel, setCurrentCenterLabel] = useState(null); + const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number } | null>(null); + const [isLocating, setIsLocating] = useState(false); + + // Optional reference to user's location marker + const userLocationMarkerRef = useRef(null); + + const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number }) => void) => { + const updateInternals = () => { + const c = map.getCenter(); + const z = map.getZoom(); + setMapInternals({ + zoom: Math.round(z * 100) / 100, + lat: Math.round(c.lat * 10000) / 10000, + lng: Math.round(c.lng * 10000) / 10000 + }); + }; + + const handleMoveEnd = async () => { + const c = map.getCenter(); + const z = map.getZoom(); + + if (onMapMove) { + onMapMove({ lat: c.lat, lng: c.lng, zoom: z }); + } + + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3333'; + const res = await fetch(`${apiUrl}/api/regions/reverse?lat=${c.lat}&lon=${c.lng}`); + if (res.ok) { + const json = await res.json(); + if (json.data) { + const geo = json.data; + const parts: string[] = []; + if (geo.city && geo.city !== 'unknown') parts.push(geo.city); + else if (geo.locality && geo.locality !== 'unknown') parts.push(geo.locality); + else if (geo.principalSubdivision && geo.principalSubdivision !== 'unknown') parts.push(geo.principalSubdivision); + if (geo.countryName) parts.push(geo.countryName); + + const label = parts.length > 0 ? parts.join(', ') : null; + setCurrentCenterLabel(label); + if (onMapCenterUpdate && label) { + onMapCenterUpdate(label, z); + } + } + } + } catch (e) { + console.error("Failed to reverse geocode center", e); + } + }; + + map.on('move', updateInternals); + map.on('zoom', updateInternals); + map.on('moveend', handleMoveEnd); + + // Initial set + updateInternals(); + + return () => { + map.off('move', updateInternals); + map.off('zoom', updateInternals); + map.off('moveend', handleMoveEnd); + }; + }, [onMapCenterUpdate]); + + const handleLocate = useCallback(async (map: maplibregl.Map | null) => { + if (!map) return; + setIsLocating(true); + try { + const res = await fetch('http://ip-api.com/json/'); + if (res.ok) { + const data = await res.json(); + if (data.status === 'success' && data.lat && data.lon) { + if (userLocationMarkerRef.current) { + userLocationMarkerRef.current.remove(); + } + const marker = new maplibregl.Marker({ color: '#ef4444' }) + .setLngLat([data.lon, data.lat]) + .setPopup(new maplibregl.Popup({ closeButton: false }).setText('Your Location (IP)')) + .addTo(map); + + userLocationMarkerRef.current = marker; + + map.flyTo({ + center: [data.lon, data.lat], + zoom: 12 + }); + } + } + } catch (e) { + console.error("Error during IP geolocation", e); + } finally { + setIsLocating(false); + } + }, []); + + const cleanupLocateMarker = useCallback(() => { + if (userLocationMarkerRef.current) { + userLocationMarkerRef.current.remove(); + userLocationMarkerRef.current = null; + } + }, []); + + return { + mapInternals, + currentCenterLabel, + isLocating, + setupMapListeners, + handleLocate, + cleanupLocateMarker + }; +} diff --git a/packages/ui/src/modules/places/utils/grid-generator.ts b/packages/ui/src/modules/places/utils/grid-generator.ts index 0586ffec..e5073297 100644 --- a/packages/ui/src/modules/places/utils/grid-generator.ts +++ b/packages/ui/src/modules/places/utils/grid-generator.ts @@ -1,16 +1,23 @@ import * as turf from '@turf/turf'; export interface GridGeneratorOptions { - features: any[]; - gridMode: 'hex' | 'square' | 'admin'; + features: any[]; // The selected region polygons + gridMode: 'hex' | 'square' | 'admin' | 'centers'; cellSize: number; cellOverlap?: number; // e.g. 0.1 for 10% overlap + centroidOverlap?: number; // e.g. 0.5 for 50% max allowable overlap between centers maxCellsLimit?: number; maxElevation: number; minDensity: number; - pathOrder: 'zigzag' | 'snake' | 'spiral-out' | 'spiral-in'; + minGhsPop?: number; + minGhsBuilt?: number; + pathOrder: 'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'; groupByRegion: boolean; + ghsFilterMode?: 'AND' | 'OR'; + allowMissingGhs?: boolean; onFilterCell?: (cell: any) => boolean; + skipPolygons?: any[]; + bypassFilters?: boolean; } export interface GridGeneratorResult { @@ -19,25 +26,39 @@ export interface GridGeneratorResult { error?: string; } -export function generateGridSearchCells(options: GridGeneratorOptions): GridGeneratorResult { - const { - features, gridMode, cellSize, maxElevation, - minDensity, pathOrder, groupByRegion, onFilterCell, - cellOverlap = 0, maxCellsLimit = 15000 +export async function generateGridSearchCells( + options: GridGeneratorOptions, + onProgress?: (stats: { current: number, total: number, validCells: any[], skippedCells: any[] }) => Promise +): Promise { + const { + features, gridMode, cellSize, maxElevation, + minDensity, minGhsPop = 0, minGhsBuilt = 0, pathOrder, groupByRegion, onFilterCell, + cellOverlap = 0, centroidOverlap = 0.5, ghsFilterMode = 'AND', maxCellsLimit = 15000, skipPolygons, allowMissingGhs = false } = options; let validCells: any[] = []; let skippedCells: any[] = []; - if (gridMode === 'admin') { // MODE: Admin Regions (GADM native polygons directly act as grid cells) - features.forEach((f, i) => { + for (let i = 0; i < features.length; i++) { + const f = features[i]; + + if (i % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ + current: i, + total: features.length, + validCells, + skippedCells + }); + if (!shouldContinue) { + break; + } + } + const props = f.properties || {}; let allowed = true; let reason = ''; - const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); - if (props.avgElevation !== undefined && maxElevation > 0) { if (props.avgElevation > maxElevation) { allowed = false; @@ -53,6 +74,35 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene } } + if (allowed) { + const checkPop = minGhsPop > 0; + const checkBuilt = minGhsBuilt > 0; + if (checkPop || checkBuilt) { + const hasPopData = props.ghsPopulation !== undefined; + const hasBuiltData = props.ghsBuiltWeight !== undefined; + const ghsPop = props.ghsPopulation || 0; + const ghsBuilt = props.ghsBuiltWeight || 0; + const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); + const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); + + if (ghsFilterMode === 'OR') { + if (checkPop && checkBuilt && !popPass && !builtPass) { + allowed = false; reason = `GHS (OR) Pop < ${minGhsPop} & Built < ${minGhsBuilt}`; + } else if (checkPop && !checkBuilt && !popPass) { + allowed = false; reason = `GHS Pop < ${minGhsPop}`; + } else if (checkBuilt && !checkPop && !builtPass) { + allowed = false; reason = `GHS Built < ${minGhsBuilt}`; + } + } else { + if (checkPop && !popPass) { + allowed = false; reason = `GHS Pop ${Math.round(ghsPop)} < ${minGhsPop}`; + } else if (checkBuilt && !builtPass) { + allowed = false; reason = `GHS Built ${Math.round(ghsBuilt)} < ${minGhsBuilt}`; + } + } + } + } + if (allowed && onFilterCell && !onFilterCell(f)) allowed = false; let center = null; @@ -63,6 +113,17 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene radiusKm = turf.distance(center, [b[2], b[3]], { units: 'kilometers' }); } + if (allowed && center && skipPolygons && skipPolygons.length > 0) { + const pt = turf.point(center); + for (const oldPoly of skipPolygons) { + if (turf.booleanPointInPolygon(pt, oldPoly)) { + allowed = false; + reason = 'Already Simulated'; + break; + } + } + } + if (allowed) { validCells.push({ ...f, @@ -88,17 +149,130 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene } }); } - }); + } + } else if (gridMode === 'centers') { + // MODE: GHS Centers - generate a cell exactly at ghsPopCenter and ghsBuiltCenter + const acceptedCenters: any[] = []; + + for (let i = 0; i < features.length; i++) { + const f = features[i]; + const props = f.properties || {}; + + if (i % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ current: i, total: features.length, validCells, skippedCells }); + if (!shouldContinue) break; + } + + const centers: [number, number][] = []; + if (props.ghsPopCenter && Array.isArray(props.ghsPopCenter)) centers.push(props.ghsPopCenter as [number, number]); + if (props.ghsBuiltCenter && Array.isArray(props.ghsBuiltCenter)) centers.push(props.ghsBuiltCenter as [number, number]); + + // Deduplicate centers + const uniqueCenters: [number, number][] = []; + for (const c of centers) { + if (!uniqueCenters.some(uc => uc[0] === c[0] && uc[1] === c[1])) { + uniqueCenters.push(c); + } + } + + for (let j = 0; j < uniqueCenters.length; j++) { + const center = uniqueCenters[j]; + const pt = turf.point(center); + + let allowed = true; + let reason = ''; + const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); + + if (!options.bypassFilters) { + if (props.avgElevation !== undefined && maxElevation > 0 && props.avgElevation > maxElevation) { + allowed = false; reason = `elevation > ${maxElevation}`; + } else if (props.population !== undefined && minDensity > 0 && (props.population / areaSqKm) < minDensity) { + allowed = false; reason = `density < ${minDensity}`; + } else { + const checkPop = minGhsPop > 0; + const checkBuilt = minGhsBuilt > 0; + if (checkPop || checkBuilt) { + const hasPopData = props.ghsPopulation !== undefined; + const hasBuiltData = props.ghsBuiltWeight !== undefined; + const ghsPop = typeof props.ghsPopulation === 'number' ? props.ghsPopulation : 0; + const ghsBuilt = typeof props.ghsBuiltWeight === 'number' ? props.ghsBuiltWeight : 0; + const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); + const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); + + if (ghsFilterMode === 'OR') { + if (checkPop && checkBuilt && !popPass && !builtPass) { + allowed = false; reason = `ghs (OR) < min`; + } else if (checkPop && !checkBuilt && !popPass) { + allowed = false; reason = `ghsPop < ${minGhsPop}`; + } else if (checkBuilt && !checkPop && !builtPass) { + allowed = false; reason = `ghsBuilt < ${minGhsBuilt}`; + } + } else { + if (checkPop && !popPass) { allowed = false; reason = `ghsPop < ${minGhsPop}`; } + if (checkBuilt && !builtPass) { allowed = false; reason = `ghsBuilt < ${minGhsBuilt}`; } + } + } + } + } + + // Check overlap with ALREADY processed center cells + if (allowed && acceptedCenters.length > 0) { + const minAllowedDistance = cellSize * (1 - centroidOverlap); + for (const existingPt of acceptedCenters) { + const dist = turf.distance(pt, existingPt, { units: 'kilometers' }); + if (dist < minAllowedDistance) { + allowed = false; + reason = `overlaps another centroid (${dist.toFixed(2)}km < ${minAllowedDistance.toFixed(2)}km)`; + break; + } + } + } + + if (allowed && skipPolygons && skipPolygons.length > 0) { + for (const oldPoly of skipPolygons) { + if (turf.booleanPointInPolygon(pt, oldPoly)) { + allowed = false; + reason = `already processed`; + break; + } + } + } + + // Create a hexagon using buffer with 6 steps around the center point + const cell = turf.buffer(pt, cellSize / 2, { units: 'kilometers', steps: 6 }); + + if (cell) { + // Add parent properties + cell.properties = { ...props, is_center_cell: true }; + + if (onFilterCell && !onFilterCell(cell) && allowed) { + allowed = false; reason = `custom filter`; + } + + cell.properties.sim_region_idx = i; + + if (allowed) { + cell.properties.sim_status = 'pending'; + validCells.push(cell); + acceptedCenters.push(pt); + } else { + cell.properties.sim_status = 'skipped'; + cell.properties.sim_skip_reason = reason; + skippedCells.push(cell); + } + } + } + } } else { // MODE: Hex Grid Projection const fc = turf.featureCollection(features); - + // 2. Get Bounding Box const bbox = turf.bbox(fc); // Safety Check: Estimate cell count - const width = turf.distance([bbox[0], bbox[1]], [bbox[2], bbox[1]], {units: 'kilometers'}); - const height = turf.distance([bbox[0], bbox[1]], [bbox[0], bbox[3]], {units: 'kilometers'}); + const width = turf.distance([bbox[0], bbox[1]], [bbox[2], bbox[1]], { units: 'kilometers' }); + const height = turf.distance([bbox[0], bbox[1]], [bbox[0], bbox[3]], { units: 'kilometers' }); const area = width * height; const approxCellArea = cellSize * cellSize * 2.6; // hexagon area approx const approxCells = Math.ceil(area / approxCellArea); @@ -127,7 +301,23 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene } // 4. Intersect and Filter - turf.featureEach(grid, (cell) => { + const totalFeatures = grid.features.length; + for (let idx = 0; idx < totalFeatures; idx++) { + const cell = grid.features[idx]; + + // Chunk checking: yield every 50 iterations or every 100 valid cells if callback provided + if (idx % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ + current: idx, + total: totalFeatures, + validCells, + skippedCells + }); + if (!shouldContinue) { + break; // Abort processing, keep what we have + } + } + // Check if cell intersects any of the picker regions let intersects = false; let regionIndex = -1; @@ -165,12 +355,52 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene } } + if (allowed) { + const checkPop = minGhsPop > 0; + const checkBuilt = minGhsBuilt > 0; + if (checkPop || checkBuilt) { + const hasPopData = props.ghsPopulation !== undefined; + const hasBuiltData = props.ghsBuiltWeight !== undefined; + const ghsPop = props.ghsPopulation || 0; + const ghsBuilt = props.ghsBuiltWeight || 0; + const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); + const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); + + if (ghsFilterMode === 'OR') { + if (checkPop && checkBuilt && !popPass && !builtPass) { + allowed = false; reason = `GHS (OR) Pop < ${minGhsPop} & Built < ${minGhsBuilt}`; + } else if (checkPop && !checkBuilt && !popPass) { + allowed = false; reason = `GHS Pop < ${minGhsPop}`; + } else if (checkBuilt && !checkPop && !builtPass) { + allowed = false; reason = `GHS Built < ${minGhsBuilt}`; + } + } else { + if (checkPop && !popPass) { + allowed = false; reason = `GHS Pop ${Math.round(ghsPop)} < ${minGhsPop}`; + } else if (checkBuilt && !builtPass) { + allowed = false; reason = `GHS Built ${Math.round(ghsBuilt)} < ${minGhsBuilt}`; + } + } + } + } + const center = turf.centroid(cell).geometry.coordinates; const cellBbox = turf.bbox(cell); const radiusKm = turf.distance(center, [cellBbox[2], cellBbox[3]], { units: 'kilometers' }); + if (allowed && center && skipPolygons && skipPolygons.length > 0) { + const pt = turf.point(center); + for (const oldPoly of skipPolygons) { + if (turf.booleanPointInPolygon(pt, oldPoly)) { + allowed = false; + reason = 'Already Simulated'; + break; + } + } + } + if (allowed) { - cell.properties = { + cell.properties = { ...cell.properties, sim_status: 'pending', sim_region_idx: regionIndex, @@ -180,7 +410,7 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene }; validCells.push(cell); } else { - cell.properties = { + cell.properties = { ...cell.properties, sim_status: 'skipped', sim_region_idx: regionIndex, @@ -191,7 +421,7 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene skippedCells.push(cell); } } - }); + } } // end hex mode // 5. Apply Path Trajectory Sorting @@ -231,7 +461,7 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene if (currentRow.length > 0) rows.push(currentRow); // Reconstruct validCells array directly into the passed array reference by clearing and pushing - cells.length = 0; + cells.length = 0; rows.forEach((row, i) => { if (i % 2 === 1) { cells.push(...row.reverse()); @@ -245,7 +475,7 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene // Find geometric center of all valid cells const fc = turf.featureCollection(cells); const center = turf.center(fc).geometry.coordinates; - + cells.sort((a, b) => { const centA = turf.centroid(a).geometry.coordinates; const centB = turf.centroid(b).geometry.coordinates; @@ -253,6 +483,37 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene const distB = turf.distance(center, centB); return pathOrder === 'spiral-out' ? distA - distB : distB - distA; }); + } else if (pathOrder === 'shortest') { + if (cells.length > 1) { + const sorted = []; + const pts = cells.map(c => ({ + cell: c, + pt: turf.centroid(c).geometry.coordinates + })); + + let current = pts.shift()!; + sorted.push(current.cell); + + while (pts.length > 0) { + let nearestIdx = 0; + let minDistSq = Infinity; + for (let i = 0; i < pts.length; i++) { + const dx = pts[i].pt[0] - current.pt[0]; + const dy = pts[i].pt[1] - current.pt[1]; + const distSq = dx * dx + dy * dy; // fast euclidean squared distance + if (distSq < minDistSq) { + minDistSq = distSq; + nearestIdx = i; + } + } + current = pts[nearestIdx]; + sorted.push(current.cell); + pts.splice(nearestIdx, 1); + } + + cells.length = 0; + cells.push(...sorted); + } } }; @@ -263,9 +524,9 @@ export function generateGridSearchCells(options: GridGeneratorOptions): GridGene if (!grouped[idx]) grouped[idx] = []; grouped[idx].push(c); }); - + validCells = []; - Object.keys(grouped).sort((a,b) => Number(a)-Number(b)).forEach(k => { + Object.keys(grouped).sort((a, b) => Number(a) - Number(b)).forEach(k => { const cells = grouped[Number(k)]; sortArray(cells); validCells.push(...cells); diff --git a/packages/ui/src/modules/places/utils/poster-themes.ts b/packages/ui/src/modules/places/utils/poster-themes.ts new file mode 100644 index 00000000..4e9ea1f6 --- /dev/null +++ b/packages/ui/src/modules/places/utils/poster-themes.ts @@ -0,0 +1,273 @@ +export interface PosterTheme { + name: string; + description?: string; + bg: string; + text: string; + gradient_color: string; + water: string; + parks: string; + road_motorway: string; + road_primary: string; + road_secondary: string; + road_tertiary: string; + road_residential: string; + road_default: string; +} + +export const POSTER_THEMES: Record = { + "autumn": { + "name": "Autumn", + "description": "Burnt oranges, deep reds, golden yellows - seasonal warmth", + "bg": "#FBF7F0", + "text": "#8B4513", + "gradient_color": "#FBF7F0", + "water": "#D8CFC0", + "parks": "#E8E0D0", + "road_motorway": "#8B2500", + "road_primary": "#B8450A", + "road_secondary": "#CC7A30", + "road_tertiary": "#D9A050", + "road_residential": "#E8C888", + "road_default": "#CC7A30" + }, + "blueprint": { + "name": "Blueprint", + "description": "Classic architectural blueprint - technical drawing aesthetic", + "bg": "#1A3A5C", + "text": "#E8F4FF", + "gradient_color": "#1A3A5C", + "water": "#0F2840", + "parks": "#1E4570", + "road_motorway": "#E8F4FF", + "road_primary": "#C5DCF0", + "road_secondary": "#9FC5E8", + "road_tertiary": "#7BAED4", + "road_residential": "#5A96C0", + "road_default": "#7BAED4" + }, + "contrast_zones": { + "name": "Contrast Zones", + "description": "Strong contrast showing urban density - darker in center, lighter at edges", + "bg": "#FFFFFF", + "text": "#000000", + "gradient_color": "#FFFFFF", + "water": "#B0B0B0", + "parks": "#ECECEC", + "road_motorway": "#000000", + "road_primary": "#0F0F0F", + "road_secondary": "#252525", + "road_tertiary": "#404040", + "road_residential": "#5A5A5A", + "road_default": "#404040" + }, + "copper_patina": { + "name": "Copper Patina", + "description": "Oxidized copper aesthetic - teal-green patina with copper accents", + "bg": "#E8F0F0", + "text": "#2A5A5A", + "gradient_color": "#E8F0F0", + "water": "#C0D8D8", + "parks": "#D8E8E0", + "road_motorway": "#B87333", + "road_primary": "#5A8A8A", + "road_secondary": "#6B9E9E", + "road_tertiary": "#88B4B4", + "road_residential": "#A8CCCC", + "road_default": "#88B4B4" + }, + "emerald": { + "name": "Emerald City", + "description": "Lush dark green aesthetic with mint accents", + "bg": "#062C22", + "text": "#E3F9F1", + "gradient_color": "#062C22", + "water": "#0D4536", + "parks": "#0F523E", + "road_motorway": "#4ADEB0", + "road_primary": "#2DB88F", + "road_secondary": "#249673", + "road_tertiary": "#1B7559", + "road_residential": "#155C46", + "road_default": "#155C46" + }, + "forest": { + "name": "Forest", + "description": "Deep greens and sage tones - organic botanical aesthetic", + "bg": "#F0F4F0", + "text": "#2D4A3E", + "gradient_color": "#F0F4F0", + "water": "#B8D4D4", + "parks": "#D4E8D4", + "road_motorway": "#2D4A3E", + "road_primary": "#3D6B55", + "road_secondary": "#5A8A70", + "road_tertiary": "#7AAA90", + "road_residential": "#A0C8B0", + "road_default": "#7AAA90" + }, + "gradient_roads": { + "name": "Gradient Roads", + "description": "Smooth gradient from dark center to light edges with subtle features", + "bg": "#FFFFFF", + "text": "#000000", + "gradient_color": "#FFFFFF", + "water": "#D5D5D5", + "parks": "#EFEFEF", + "road_motorway": "#050505", + "road_primary": "#151515", + "road_secondary": "#2A2A2A", + "road_tertiary": "#404040", + "road_residential": "#555555", + "road_default": "#404040" + }, + "japanese_ink": { + "name": "Japanese Ink", + "description": "Traditional ink wash inspired - minimalist with subtle red accent", + "bg": "#FAF8F5", + "text": "#2C2C2C", + "gradient_color": "#FAF8F5", + "water": "#E8E4E0", + "parks": "#F0EDE8", + "road_motorway": "#8B2500", + "road_primary": "#4A4A4A", + "road_secondary": "#6A6A6A", + "road_tertiary": "#909090", + "road_residential": "#B8B8B8", + "road_default": "#909090" + }, + "midnight_blue": { + "name": "Midnight Blue", + "description": "Deep navy background with gold/copper roads - luxury atlas aesthetic", + "bg": "#0A1628", + "text": "#D4AF37", + "gradient_color": "#0A1628", + "water": "#061020", + "parks": "#0F2235", + "road_motorway": "#D4AF37", + "road_primary": "#C9A227", + "road_secondary": "#A8893A", + "road_tertiary": "#8B7355", + "road_residential": "#6B5B4F", + "road_default": "#8B7355" + }, + "monochrome_blue": { + "name": "Monochrome Blue", + "description": "Single blue color family with varying saturation - clean and cohesive", + "bg": "#F5F8FA", + "text": "#1A3A5C", + "gradient_color": "#F5F8FA", + "water": "#D0E0F0", + "parks": "#E0EAF2", + "road_motorway": "#1A3A5C", + "road_primary": "#2A5580", + "road_secondary": "#4A7AA8", + "road_tertiary": "#7AA0C8", + "road_residential": "#A8C4E0", + "road_default": "#4A7AA8" + }, + "neon_cyberpunk": { + "name": "Neon Cyberpunk", + "description": "Dark background with electric pink/cyan - bold night city vibes", + "bg": "#0D0D1A", + "text": "#00FFFF", + "gradient_color": "#0D0D1A", + "water": "#0A0A15", + "parks": "#151525", + "road_motorway": "#FF00FF", + "road_primary": "#00FFFF", + "road_secondary": "#00C8C8", + "road_tertiary": "#0098A0", + "road_residential": "#006870", + "road_default": "#0098A0" + }, + "noir": { + "name": "Noir", + "description": "Pure black background with white/gray roads - modern gallery aesthetic", + "bg": "#000000", + "text": "#FFFFFF", + "gradient_color": "#000000", + "water": "#0A0A0A", + "parks": "#111111", + "road_motorway": "#FFFFFF", + "road_primary": "#E0E0E0", + "road_secondary": "#B0B0B0", + "road_tertiary": "#808080", + "road_residential": "#505050", + "road_default": "#808080" + }, + "ocean": { + "name": "Ocean", + "description": "Various blues and teals - perfect for coastal cities", + "bg": "#F0F8FA", + "text": "#1A5F7A", + "gradient_color": "#F0F8FA", + "water": "#B8D8E8", + "parks": "#D8EAE8", + "road_motorway": "#1A5F7A", + "road_primary": "#2A7A9A", + "road_secondary": "#4A9AB8", + "road_tertiary": "#70B8D0", + "road_residential": "#A0D0E0", + "road_default": "#4A9AB8" + }, + "pastel_dream": { + "name": "Pastel Dream", + "description": "Soft muted pastels with dusty blues and mauves - dreamy artistic aesthetic", + "bg": "#FAF7F2", + "text": "#5D5A6D", + "gradient_color": "#FAF7F2", + "water": "#D4E4ED", + "parks": "#E8EDE4", + "road_motorway": "#7B8794", + "road_primary": "#9BA4B0", + "road_secondary": "#B5AEBB", + "road_tertiary": "#C9C0C9", + "road_residential": "#D8D2D8", + "road_default": "#C9C0C9" + }, + "sunset": { + "name": "Sunset", + "description": "Warm oranges and pinks on soft peach - dreamy golden hour aesthetic", + "bg": "#FDF5F0", + "text": "#C45C3E", + "gradient_color": "#FDF5F0", + "water": "#F0D8D0", + "parks": "#F8E8E0", + "road_motorway": "#C45C3E", + "road_primary": "#D87A5A", + "road_secondary": "#E8A088", + "road_tertiary": "#F0B8A8", + "road_residential": "#F5D0C8", + "road_default": "#E8A088" + }, + "terracotta": { + "name": "Terracotta", + "description": "Mediterranean warmth - burnt orange and clay tones on cream", + "bg": "#F5EDE4", + "text": "#8B4513", + "gradient_color": "#F5EDE4", + "water": "#A8C4C4", + "parks": "#E8E0D0", + "road_motorway": "#A0522D", + "road_primary": "#B8653A", + "road_secondary": "#C9846A", + "road_tertiary": "#D9A08A", + "road_residential": "#E5C4B0", + "road_default": "#D9A08A" + }, + "warm_beige": { + "name": "Warm Beige", + "description": "Earthy warm neutrals with sepia tones - vintage map aesthetic", + "bg": "#F5F0E8", + "text": "#6B5B4F", + "gradient_color": "#F5F0E8", + "water": "#DDD5C8", + "parks": "#E8E4D8", + "road_motorway": "#8B7355", + "road_primary": "#A08B70", + "road_secondary": "#B5A48E", + "road_tertiary": "#C9BBAA", + "road_residential": "#D9CFC2", + "road_default": "#C9BBAA" + }, +};