Maintenance Love :)

This commit is contained in:
lovebird 2026-03-22 17:35:50 +01:00
parent 0906b299f4
commit c052eebca9
12 changed files with 2392 additions and 664 deletions

View File

@ -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<CompetitorsMapViewProps> = ({ competit
const map = useRef<maplibregl.Map | null>(null);
const { theme, systemTheme } = useTheme();
const currentTheme = theme === 'system' ? systemTheme : theme;
const [mapStyle, setMapStyle] = useState<any>(currentTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light);
const [mapStyleKey, setMapStyleKey] = useState<MapStyleKey>(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<CompetitorsMapViewProps> = ({ competit
// Info Panel State
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
// State for visual feedback
const [currentCenterLabel, setCurrentCenterLabel] = useState<string | null>(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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ competit
const userLocationMarkerRef = useRef<maplibregl.Marker | null>(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 (
<div className="space-y-8">
@ -478,11 +328,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<div className="w-full max-w-sm flex items-center gap-2">
<button
onClick={() => setGadmPickerActive(!gadmPickerActive)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
gadmPickerActive
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300'
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${gadmPickerActive
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700'
}`}
}`}
title="Toggle GADM Area Selector"
>
<MapIcon className="w-4 h-4" />
@ -508,71 +357,43 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Map Viewport */}
<div className="relative flex-1 min-h-0 w-full">
<div ref={mapContainer} className="w-full h-full" />
{gadmPickerActive && (
<div className="absolute top-4 left-4 z-10 w-96 shadow-xl rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<GadmPicker
map={map.current}
active={gadmPickerActive}
onClose={() => setGadmPickerActive(false)}
<GadmPicker
map={map.current}
active={gadmPickerActive}
onClose={() => setGadmPickerActive(false)}
/>
</div>
)}
</div>
{/* Footer: Status Info & Toggles */}
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20">
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400">
{currentCenterLabel ? (
<div className="font-medium text-gray-900 dark:text-gray-200 flex items-center gap-1">
<MapIcon className="w-3 h-3 text-indigo-500" />
{currentCenterLabel}
</div>
) : (
<span>Pan map to view details</span>
)}
{mapInternals && (
<div className="font-mono flex gap-4 opacity-80 pl-4 border-l border-gray-300 dark:border-gray-600">
<span>Lat: {mapInternals.lat.toFixed(4)}</span>
<span>Lng: {mapInternals.lng.toFixed(4)}</span>
<span>Zoom: {mapInternals.zoom.toFixed(1)}</span>
</div>
)}
</div>
{/* Style Toggles */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button
onClick={handleLocate}
disabled={isLocating}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isLocating ? 'text-indigo-500' : 'text-gray-500'}`}
title="Locate Me (IP)"
>
{isLocating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Locate className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleZoomToFit}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500"
title="Zoom to Results"
>
<Maximize className="w-3.5 h-3.5" />
</button>
<button onClick={() => setMapStyle(MAP_STYLES.light)} className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${mapStyle === MAP_STYLES.light ? 'text-indigo-600 font-medium' : 'text-gray-500'}`} title="Light Mode"><Sun className="w-3.5 h-3.5" /></button>
<button onClick={() => setMapStyle(MAP_STYLES.osm_raster)} className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${mapStyle === MAP_STYLES.osm_raster ? 'text-indigo-600 font-medium' : 'text-gray-500'}`} title="OSM"><MapIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setMapStyle(MAP_STYLES.dark)} className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${mapStyle === MAP_STYLES.dark ? 'text-indigo-600 font-medium' : 'text-gray-500'}`} title="Dark Mode"><Moon className="w-3.5 h-3.5" /></button>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title="Enrich Visible Locations"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</div>
<MapFooter
currentCenterLabel={currentCenterLabel}
mapInternals={mapInternals}
isLocating={isLocating}
onLocate={() => 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}
>
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title="Enrich Visible Locations"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
{enrichmentProgress && (
<div className="absolute bottom-10 right-4 bg-white dark:bg-gray-800 p-2 rounded shadow text-xs border border-gray-200 dark:border-gray-700 flex items-center gap-2 z-50">
@ -580,7 +401,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<span>{enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total})</span>
</div>
)}
</div>
</MapFooter>
</div>
{/* Resizable Handle and Property Pane */}

View File

@ -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<HTMLDivElement>(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 (
<div className="flex h-[calc(100vh-64px)] [&:fullscreen]:h-screen w-full bg-white dark:bg-gray-900">
<div ref={containerRef} className="flex h-[calc(100svh-64px)] [&:fullscreen]:h-svh w-full bg-white dark:bg-gray-900 relative">
{/* ── Sidebar ────────────────────────────────────────────────── */}
<div className="w-96 flex-shrink-0 bg-white dark:bg-gray-900 border-r dark:border-gray-800 p-4 flex flex-col gap-6 overflow-y-auto">
<h2 className="text-xl font-bold font-mono text-gray-800 dark:text-gray-100">GridSearch Playground</h2>
{!state.posterMode && (
<div className="w-96 flex-shrink-0 bg-white dark:bg-gray-900 border-r dark:border-gray-800 p-4 pb-12 flex flex-col gap-6 overflow-y-auto z-10">
<h2 className="text-xl font-bold font-mono text-gray-800 dark:text-gray-100">GridSearch Playground</h2>
<CollapsibleSection title="Generate New Search" initiallyOpen={false} minimal>
<GenerateGridForm
includeStats={state.includeStats}
onToggleStats={state.setIncludeStats}
onGenerated={state.handleSearchGenerated}
/>
</CollapsibleSection>
<hr className="dark:border-gray-700" />
<CollapsibleSection title="Select Generated Search" initiallyOpen={false} minimal>
<GridSearchSelector
searches={state.searches}
selectedSearch={state.selectedSearch}
searchData={state.searchData}
loading={state.loading}
loadingPolygons={state.loadingPolygons}
onSelectSearch={state.setSelectedSearch}
onFetchPolygons={state.handleFetchPolygons}
/>
</CollapsibleSection>
<CollapsibleSection
title="GADM Area Selector"
initiallyOpen={state.gadmPickerActive}
onStateChange={state.setGadmPickerActive}
minimal
keepMounted
>
<div className="pt-2">
<GadmPicker
map={state.mapInstance}
active={state.gadmPickerActive}
onClose={() => state.setGadmPickerActive(false)}
onSelectionChange={(r, polygons) => {
state.setPickerRegions(r);
state.setPickerPolygons(polygons);
}}
/>
</div>
</CollapsibleSection>
{state.pickerRegions && state.pickerRegions.length > 0 && (
<CollapsibleSection title="GridSearch Simulator" initiallyOpen={false} minimal keepMounted>
<GridSearchSimulator
pickerRegions={state.pickerRegions}
pickerPolygons={state.pickerPolygons}
setSimulatorData={state.setSimulatorData}
setSimulatorPath={state.setSimulatorPath}
setSimulatorScanner={state.setSimulatorScanner}
onFilterCell={(cell) => {
// Example external filter implementation hook!
// e.g. return cell.properties.population > 100
return true;
}}
<CollapsibleSection title="Generate New Search" initiallyOpen={false} minimal>
<GenerateGridForm
includeStats={state.includeStats}
onToggleStats={state.setIncludeStats}
onGenerated={state.handleSearchGenerated}
/>
</CollapsibleSection>
)}
</div>
<hr className="dark:border-gray-700" />
<CollapsibleSection title="Select Generated Search" initiallyOpen={false} minimal>
<GridSearchSelector
searches={state.searches}
selectedSearch={state.selectedSearch}
searchData={state.searchData}
loading={state.loading}
loadingPolygons={state.loadingPolygons}
onSelectSearch={state.setSelectedSearch}
onFetchPolygons={state.handleFetchPolygons}
/>
</CollapsibleSection>
<CollapsibleSection
title="GADM Area Selector"
initiallyOpen={state.gadmPickerActive}
onStateChange={state.setGadmPickerActive}
minimal
keepMounted
>
<div className="pt-2">
<GadmPicker
map={state.mapInstance}
active={state.gadmPickerActive}
onClose={() => state.setGadmPickerActive(false)}
onSelectionChange={(r, polygons) => {
state.setPickerRegions(r);
state.setPickerPolygons(polygons);
}}
/>
</div>
</CollapsibleSection>
{state.pickerRegions && state.pickerRegions.length > 0 && (
<CollapsibleSection title="GridSearch Simulator" initiallyOpen={false} minimal keepMounted>
<GridSearchSimulator
pickerRegions={state.pickerRegions}
pickerPolygons={state.pickerPolygons}
setSimulatorData={state.setSimulatorData}
setSimulatorPath={state.setSimulatorPath}
setSimulatorScanner={state.setSimulatorScanner}
onFilterCell={(cell) => {
// Example external filter implementation hook!
return true;
}}
/>
</CollapsibleSection>
)}
<div className="min-h-12 flex-shrink-0" />
</div>
)}
{/* ── Map ────────────────────────────────────────────────────── */}
<GridSearchMap
bboxesFeatureCollection={state.bboxesData}
polygonsFeatureCollection={state.polygonsData}
targetBounds={state.targetBounds}
urlStyle={state.urlStyle}
showDensity={state.showDensity}
gadmPickerActive={state.gadmPickerActive}
simulatorData={state.simulatorData}
simulatorPath={state.simulatorPath}
simulatorScanner={state.simulatorScanner}
onStyleChange={state.setUrlStyle}
onToggleDensity={state.setShowDensity}
onTogglePicker={state.setGadmPickerActive}
onMapReady={state.setMapInstance}
/>
<div id="poster-capture-area" className="relative flex-1 flex flex-col min-w-0 min-h-0">
<GridSearchMap
bboxesFeatureCollection={state.bboxesData}
polygonsFeatureCollection={state.polygonsData}
pickerPolygons={state.pickerPolygons}
targetBounds={state.targetBounds}
urlStyle={state.urlStyle}
posterMode={state.posterMode}
posterTheme={state.posterTheme}
showDensity={state.showDensity}
showCenters={state.showCenters}
gadmPickerActive={state.gadmPickerActive}
simulatorData={state.simulatorData}
simulatorPath={state.simulatorPath}
simulatorScanner={state.simulatorScanner}
onStyleChange={state.setUrlStyle}
onPosterMode={state.setPosterMode}
onToggleDensity={state.setShowDensity}
onToggleCenters={state.setShowCenters}
onTogglePicker={state.setGadmPickerActive}
onMapReady={state.setMapInstance}
isFullscreen={isFullscreen}
onFullscreenToggle={toggleFullscreen}
overlayBottomLeft={
!state.posterMode ? (() => {
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 (
<div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur shadow-lg border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm min-w-[200px] mb-4 ml-4">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-2 border-b border-gray-100 dark:border-gray-700 pb-1">{label}</h4>
<div className="space-y-1 text-gray-600 dark:text-gray-400">
<div className="flex justify-between gap-4">
<span>Population:</span>
<span className="font-medium text-gray-900 dark:text-white">{totalPop.toLocaleString()}</span>
</div>
<div className="flex justify-between gap-4">
<span>Area:</span>
<span className="font-medium text-gray-900 dark:text-white">{totalArea.toLocaleString(undefined, { maximumFractionDigits: 1 })} km²</span>
</div>
</div>
</div>
);
})() : null
}
/>
{state.posterMode && (
<MapPosterOverlay
map={state.mapInstance}
pickerRegions={state.pickerRegions}
pickerPolygons={state.pickerPolygons}
posterTheme={state.posterTheme}
setPosterTheme={state.setPosterTheme}
onClose={() => state.setPosterMode(false)}
/>
)}
</div>
</div>
);
}

View File

@ -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: '&copy; 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<HTMLDivElement>(null);
const mapWrapper = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(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<any>(() => {
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<MapStyleKey>(() => {
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="30" height="30"><path d="M50,50 L100,25 A50,50 0 1,0 100,75 Z" fill="#eab308"/></svg>`;
const pacmanClosedSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="30" height="30"><circle cx="50" cy="50" r="50" fill="#eab308"/></svg>`;
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 (
<div ref={mapWrapper} className="flex-1 relative min-h-0 w-full">
<div ref={mapContainer} className="w-full h-full" />
{/* Toolbar */}
<div className="absolute top-4 left-4 z-10 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-1">
<div className="flex flex-col gap-1">
<button
onClick={() => setMapStyle(MAP_STYLES.light)}
className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${mapStyle === MAP_STYLES.light ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600' : 'text-gray-600 dark:text-gray-400'}`}
title="Light Mode"
>
<Sun className="w-5 h-5" />
</button>
<button
onClick={() => setMapStyle(MAP_STYLES.dark)}
className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${mapStyle === MAP_STYLES.dark ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600' : 'text-gray-600 dark:text-gray-400'}`}
title="Dark Mode"
>
<Moon className="w-5 h-5" />
</button>
<div className="w-full border-t border-gray-200 dark:border-gray-600 my-0.5" />
<button
onClick={() => onToggleDensity?.(!showDensity)}
className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${showDensity ? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600' : 'text-gray-600 dark:text-gray-400'}`}
title="Population Density"
>
<Users className="w-5 h-5" />
</button>
<button
onClick={() => onTogglePicker?.(!gadmPickerActive)}
className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${gadmPickerActive ? 'bg-yellow-50 dark:bg-yellow-900/30 text-yellow-600' : 'text-gray-600 dark:text-gray-400'}`}
title="GADM Area Selector"
>
<MousePointerClick className="w-5 h-5" />
</button>
</div>
<div ref={mapWrapper} className="flex-1 relative w-full h-full flex flex-col min-h-0 min-w-0 bg-transparent overflow-hidden">
<div className="relative flex-1 min-h-0 min-w-0 w-full h-full bg-slate-100 dark:bg-slate-900">
<div ref={mapContainer} className="absolute inset-0" />
{overlayBottomLeft && (
<div className="absolute bottom-4 left-4 z-10 pointer-events-auto">
{overlayBottomLeft}
</div>
)}
</div>
<MapFooter
currentCenterLabel={currentCenterLabel}
mapInternals={mapInternals}
isLocating={isLocating}
onLocate={() => 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}
>
<button
onClick={() => onToggleDensity?.(!showDensity)}
className={`p-1 flex items-center gap-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${showDensity ? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600' : 'text-gray-500'} font-medium`}
title="Population Density"
>
<Users className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Density</span>
</button>
<button
onClick={() => onToggleCenters?.(!showCenters)}
className={`p-1 flex items-center gap-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${showCenters ? 'bg-purple-50 dark:bg-purple-900/30 text-purple-600' : 'text-gray-500'} font-medium`}
title="Population & Built Centers"
>
<MapPin className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Centers</span>
</button>
<button
onClick={() => onPosterMode?.(!posterMode)}
className={`p-1 flex items-center gap-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${posterMode ? 'bg-orange-50 dark:bg-orange-900/30 text-orange-600' : 'text-gray-500'} font-medium`}
title="Poster View"
>
<Palette className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Poster</span>
</button>
<button
onClick={() => onTogglePicker?.(!gadmPickerActive)}
className={`p-1 flex items-center gap-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${gadmPickerActive ? 'bg-yellow-50 dark:bg-yellow-900/30 text-yellow-600' : 'text-gray-500'} font-medium`}
title="GADM Area Selector"
>
<MousePointerClick className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Regions</span>
</button>
</MapFooter>
</div>
);
}

View File

@ -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 (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20">
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400">
{currentCenterLabel ? (
<div className="font-medium text-gray-900 dark:text-gray-200 flex items-center gap-1">
<MapIcon className="w-3 h-3 text-indigo-500" />
{currentCenterLabel}
</div>
) : (
<span>Pan map to view details</span>
)}
{mapInternals && (
<div className="font-mono flex gap-4 opacity-80 pl-4 border-l border-gray-300 dark:border-gray-600">
<span>Lat: {mapInternals.lat.toFixed(4)}</span>
<span>Lng: {mapInternals.lng.toFixed(4)}</span>
<span>Zoom: {mapInternals.zoom.toFixed(1)}</span>
</div>
)}
</div>
{/* Style Toggles & Actions */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
{onLocate && (
<button
onClick={onLocate}
disabled={isLocating}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isLocating ? 'text-indigo-500' : 'text-gray-500'}`}
title="Locate Me (IP)"
>
{isLocating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Locate className="w-3.5 h-3.5" />}
</button>
)}
{onZoomToFit && (
<button
onClick={onZoomToFit}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500"
title="Zoom to Fit"
>
<Focus className="w-3.5 h-3.5" />
</button>
)}
{onFullscreenToggle && (
<button
onClick={onFullscreenToggle}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isFullscreen ? 'text-indigo-600 font-medium' : 'text-gray-500'}`}
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
>
{isFullscreen ? <Minimize className="w-3.5 h-3.5" /> : <Maximize className="w-3.5 h-3.5" />}
</button>
)}
{onStyleChange && (
<>
<button
onClick={() => onStyleChange('light')}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${activeStyleKey === 'light' ? 'text-indigo-600 font-medium' : 'text-gray-500'}`}
title="Light Mode"
>
<Sun className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onStyleChange('osm_raster')}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${activeStyleKey === 'osm_raster' ? 'text-indigo-600 font-medium' : 'text-gray-500'}`}
title="OSM Raster"
>
<MapIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onStyleChange('dark')}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${activeStyleKey === 'dark' ? 'text-indigo-600 font-medium' : 'text-gray-500'}`}
title="Dark Mode"
>
<Moon className="w-3.5 h-3.5" />
</button>
</>
)}
{children && (
<>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600 mx-2"></div>
{children}
</>
)}
</div>
</div>
);
}

View File

@ -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 }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
);
const PaletteIcon = ({ className }: { className?: string }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>
);
interface MapPosterOverlayProps {
map: maplibregl.Map | null;
pickerRegions: any[];
pickerPolygons?: any[];
posterTheme: string;
setPosterTheme: (theme: string) => void;
onClose: () => void;
}
const MapIcon = ({ className }: { className?: string }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21 3 6"></polygon><line x1="9" y1="3" x2="9" y2="18"></line><line x1="15" y1="6" x2="15" y2="21"></line></svg>
);
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 (
<div className="absolute inset-0 z-40 pointer-events-none flex flex-col justify-between overflow-hidden mix-blend-normal">
{/* Top Gradient */}
<div className="w-full h-32 absolute top-0 left-0" style={{ background: gradientTop }} />
{/* Controls (pointer events auto) */}
<div id="poster-controls" className="absolute top-4 right-4 flex gap-2 pointer-events-auto z-50">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur rounded flex items-center p-1 border border-gray-200 dark:border-gray-700 shadow-sm text-gray-800 dark:text-gray-200">
<PaletteIcon className="w-4 h-4 ml-2 mr-1 opacity-50" />
<select
value={posterTheme}
onChange={e => setPosterTheme(e.target.value)}
className="bg-transparent text-sm font-medium border-none focus:ring-0 cursor-pointer outline-none px-2 py-1"
>
{Object.keys(POSTER_THEMES).map(k => (
<option key={k} value={k}>{POSTER_THEMES[k].name}</option>
))}
</select>
</div>
{pickerPolygons && pickerPolygons.length > 0 && (
<button
onClick={() => setShowGadmBorders(!showGadmBorders)}
className={`flex items-center px-3 py-1.5 ${showGadmBorders ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100'} rounded text-sm font-medium transition-colors shadow-sm`}
title="Toggle GADM Borders"
>
<MapIcon className="w-4 h-4" />
</button>
)}
<button
onClick={onClose}
className="flex items-center px-3 py-1.5 bg-red-100 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 text-red-700 dark:text-red-300 rounded text-sm font-medium transition-colors shadow-sm"
>
<XIcon className="w-4 h-4 mr-1.5" /> Exit Poster Mode
</button>
</div>
{/* Bottom Content Area */}
<div className="w-full h-72 absolute bottom-0 left-0 flex flex-col justify-end items-center pb-12" style={{ background: gradientBottom }}>
<div
className="text-center transition-colors duration-500"
style={{ color: theme.text }}
>
<div className="font-bold tracking-[0.2em] text-5xl mb-6 font-mono leading-none drop-shadow-sm">
{displayCity}
</div>
<div className="w-20 h-[2px] mx-auto opacity-70 mb-5 drop-shadow-sm" style={{ backgroundColor: theme.text }} />
<div className="text-2xl tracking-[0.3em] font-light mb-3 drop-shadow-sm">
{country.toUpperCase()}
</div>
<div className="text-sm opacity-80 font-mono tracking-wider drop-shadow-sm">
{center ? formatCoords(center) : 'Loading...'}
</div>
</div>
{/* Attribution */}
<div
className="absolute bottom-4 right-4 text-xs opacity-50 font-mono"
style={{ color: theme.text }}
>
© OpenStreetMap contributors
</div>
</div>
</div>
);
}

View File

@ -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: '&copy; 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;

View File

@ -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<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
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<number>(0);
const [resolutionOption, setResolutionOption] = useState<number>(1);
const [enrich, setEnrich] = useState(false);
const [levelOption, setLevelOption] = useLocalStorage<number>('pm_gadm_levelOption', 0);
const [resolutionOption, setResolutionOption] = useLocalStorage<number>('pm_gadm_resolutionOption', 1);
const [enrich, setEnrich] = useLocalStorage<boolean>('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<string, any> | 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"
/>
<Label htmlFor="picker-enrich" className="text-xs text-sky-700 dark:text-sky-300 font-medium cursor-pointer">
Enrich with GeoNames data
Enrich with GHS data
</Label>
</div>
@ -666,8 +724,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
</div>
{region.stats && !loading && (
<div className="mt-1.5 text-xs text-yellow-700 dark:text-yellow-500 flex gap-4 opacity-90 border-t border-yellow-200/50 dark:border-yellow-800/50 pt-1.5 pl-3.5">
<span><strong className="font-semibold">Area:</strong> {region.stats.areaSqkm?.toLocaleString() ?? 'N/A'} km²</span>
<span><strong className="font-semibold">Pop:</strong> {region.stats.population?.toLocaleString() ?? 'N/A'}</span>
<span><strong className="font-semibold">Built:</strong> {region.stats.ghsBuiltWeight ? Math.round(region.stats.ghsBuiltWeight).toLocaleString() : 'N/A'}</span>
<span><strong className="font-semibold">Pop:</strong> {region.stats.ghsPopulation ? Math.round(region.stats.ghsPopulation).toLocaleString() : 'N/A'}</span>
</div>
)}
</div>

View File

@ -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<any[]>([]);
const [pickerPolygons, setPickerPolygons] = useState<any[]>([]);
@ -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,

View File

@ -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<string | null>(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<maplibregl.Marker | null>(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
};
}

View File

@ -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<boolean>
): Promise<GridGeneratorResult> {
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);

View File

@ -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<string, PosterTheme> = {
"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"
},
};