Maintenance Love :)
This commit is contained in:
parent
0906b299f4
commit
c052eebca9
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
119
packages/ui/src/modules/places/components/MapFooter.tsx
Normal file
119
packages/ui/src/modules/places/components/MapFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
packages/ui/src/modules/places/components/MapPosterOverlay.tsx
Normal file
199
packages/ui/src/modules/places/components/MapPosterOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
packages/ui/src/modules/places/components/map-styles.ts
Normal file
40
packages/ui/src/modules/places/components/map-styles.ts
Normal 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: '© 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;
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
116
packages/ui/src/modules/places/hooks/useMapControls.ts
Normal file
116
packages/ui/src/modules/places/hooks/useMapControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
273
packages/ui/src/modules/places/utils/poster-themes.ts
Normal file
273
packages/ui/src/modules/places/utils/poster-themes.ts
Normal 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"
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user