Maintenance Love :)

This commit is contained in:
lovebird 2026-03-22 20:28:41 +01:00
parent c052eebca9
commit 77b2f41ee0
20 changed files with 4037 additions and 1233 deletions

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
},
"dependencies": {
"@hono/zod-openapi": "^1.1.5",
"@turf/turf": "^7.3.4",
"zod": "^4.3.6"
}
}
}

View File

@ -2,4 +2,4 @@ export * from './ui/schemas.js';
export * from './ui/page-iterator.js';
export * from './competitors/schemas.js';
export * from './config/config.schema.js';
export * from './products/places/grid-generator.js';

View File

@ -244,20 +244,20 @@ export async function generateGridSearchCells(
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;
cell.properties!.sim_region_idx = i;
if (allowed) {
cell.properties.sim_status = 'pending';
cell.properties!.sim_status = 'pending';
validCells.push(cell);
acceptedCenters.push(pt);
} else {
cell.properties.sim_status = 'skipped';
cell.properties.sim_skip_reason = reason;
cell.properties!.sim_status = 'skipped';
cell.properties!.sim_skip_reason = reason;
skippedCells.push(cell);
}
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2 } from 'lucide-react';
import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid } from 'lucide-react';
import { type CompetitorFull } from '@polymech/shared';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -7,12 +7,32 @@ import { useTheme } from 'next-themes';
import { LocationDetailView } from './LocationDetail';
import { InfoPanel } from './InfoPanel';
import { GadmPicker } from './gadm-picker';
import { GridSearchSimulator } from './gridsearch/simulator/GridSearchSimulator';
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 { SimulatorLayers } from './components/map-layers/SimulatorLayers';
import { RegionLayers } from './components/map-layers/RegionLayers';
import { MapLayerToggles } from './components/MapLayerToggles';
// import { useLocationEnrichment } from './hooks/useEnrichment';
const safeSetStyle = (m: maplibregl.Map, style: any) => {
const terrain = m.getTerrain();
if (terrain) {
m.setTerrain(null);
}
m.setStyle(style);
if (terrain) {
m.once('style.load', () => {
setTimeout(() => {
try { m.setTerrain(terrain); } catch(e) {}
}, 50);
});
}
};
interface CompetitorsMapViewProps {
competitors: CompetitorFull[];
@ -108,6 +128,19 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Selection and Sidebar State
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(null);
const [gadmPickerActive, setGadmPickerActive] = useState(false);
// Grid Search Simulator State
const [simulatorActive, setSimulatorActive] = useState(false);
const [pickerRegions, setPickerRegions] = useState<any[]>([]);
const [pickerPolygons, setPickerPolygons] = useState<any[]>([]);
const [simulatorData, setSimulatorData] = useState<any>(null);
const [simulatorPath, setSimulatorPath] = useState<any>(null);
const [simulatorScanner, setSimulatorScanner] = useState<any>(null);
// Layer Toggles
const [showDensity, setShowDensity] = useState(false);
const [showCenters, setShowCenters] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(400);
const [isResizing, setIsResizing] = useState(false);
@ -253,12 +286,14 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Sync Theme/Style
useEffect(() => {
if (!map.current) return;
map.current.setStyle(MAP_STYLES[mapStyleKey]);
safeSetStyle(map.current, 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'.
}, [mapStyleKey]);
// Handle Layout Resize
useEffect(() => {
if (map.current) {
@ -327,10 +362,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<div className="flex items-center gap-3 p-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 z-20">
<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'
: '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'
onClick={() => {
setGadmPickerActive(!gadmPickerActive);
if (!gadmPickerActive) setSimulatorActive(false);
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${gadmPickerActive
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 border-gray-200 dark:border-gray-700'
}`}
title="Toggle GADM Area Selector"
>
@ -338,6 +376,22 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{gadmPickerActive ? 'Close Selector' : 'Area Selector'}
</button>
{/* Grid Search Toggle */}
<button
onClick={() => {
setSimulatorActive(!simulatorActive);
if (!simulatorActive) setGadmPickerActive(false);
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${simulatorActive
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 border-gray-200 dark:border-gray-700'
}`}
title="Toggle Grid Search Simulator"
>
<LayoutGrid className="w-4 h-4" />
Grid Search
</button>
{/* Info Button */}
<button
onClick={() => setInfoPanelOpen(!infoPanelOpen)}
@ -357,14 +411,60 @@ 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)}
{map.current && (
<>
<SimulatorLayers
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
simulatorData={simulatorData}
simulatorPath={simulatorPath}
simulatorScanner={simulatorScanner}
/>
<RegionLayers
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
pickerPolygons={pickerPolygons}
showDensity={showDensity}
showCenters={showCenters}
/>
</>
)}
<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 transition-opacity duration-200 ${gadmPickerActive ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none hidden'}`}>
<GadmPicker
map={map.current}
active={gadmPickerActive}
onClose={() => setGadmPickerActive(false)}
onSelectionChange={(r, p) => {
setPickerRegions(r);
setPickerPolygons(p);
}}
/>
</div>
{simulatorActive && (
<div className="absolute top-4 left-4 z-10 w-96 max-h-[calc(100vh-200px)] overflow-y-auto shadow-xl rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 pb-2 custom-scrollbar">
<div className="p-4 pb-2 flex justify-between items-center border-b dark:border-gray-700 mb-2">
<h3 className="font-semibold text-gray-800 dark:text-gray-200">Grid Search Simulator</h3>
<button onClick={() => setSimulatorActive(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm">Close</button>
</div>
<div className="px-2">
{(pickerRegions && pickerRegions.length > 0) ? (
<GridSearchSimulator
pickerRegions={pickerRegions}
pickerPolygons={pickerPolygons}
setSimulatorData={setSimulatorData}
setSimulatorPath={setSimulatorPath}
setSimulatorScanner={setSimulatorScanner}
onFilterCell={() => true}
/>
) : (
<div className="p-4 text-sm text-gray-500 text-center">
Please select a region using the Area Selector first to define the search bounds.
</div>
)}
</div>
</div>
)}
</div>
@ -386,6 +486,12 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
activeStyleKey={mapStyleKey}
onStyleChange={setMapStyleKey}
>
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={setShowDensity}
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}

View File

@ -3,15 +3,23 @@ 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 { GridSearchSimulator } from './gridsearch/simulator/GridSearchSimulator';
import { MapPosterOverlay } from './components/MapPosterOverlay';
import { useGridSearchState } from './hooks/useGridSearchState';
import CollapsibleSection from '@/components/CollapsibleSection';
import { useAppStore } from '@/store/appStore';
export default function GridSearchPlayground() {
const state = useGridSearchState();
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const { setShowGlobalFooter } = useAppStore();
useEffect(() => {
setShowGlobalFooter(false);
return () => setShowGlobalFooter(true);
}, [setShowGlobalFooter]);
useEffect(() => {
const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);

View File

@ -6,41 +6,25 @@ import { MAP_STYLES, type MapStyleKey } from './map-styles';
import { POSTER_THEMES } from '../utils/poster-themes';
import { MapFooter } from './MapFooter';
import { useMapControls } from '../hooks/useMapControls';
import { SimulatorLayers } from './map-layers/SimulatorLayers';
import { RegionLayers } from './map-layers/RegionLayers';
import { MapLayerToggles } from './MapLayerToggles';
function addMapSources(m: maplibregl.Map, bboxData: any, polyData: any) {
if (!m.getSource('grid-bboxes'))
m.addSource('grid-bboxes', { type: 'geojson', data: bboxData });
if (!m.getSource('grid-polygons'))
m.addSource('grid-polygons', { type: 'geojson', data: polyData });
}
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': 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': 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'))
m.addLayer({ id: 'bboxes-line', type: 'line', source: 'grid-bboxes', paint: { 'line-color': '#d97706', 'line-width': 2, 'line-dasharray': [2, 2] } });
if (!m.getLayer('density-fill'))
m.addLayer({
id: 'density-fill', type: 'fill', source: 'grid-bboxes',
layout: { visibility: densityVisible ? 'visible' : 'none' },
paint: {
'fill-color': [
'case',
['all', ['has', 'population'], ['has', 'areaSqKm'], ['>', ['get', 'areaSqKm'], 0]],
['interpolate', ['linear'], ['/', ['get', 'population'], ['get', 'areaSqKm']],
0, '#f7fcf5', 50, '#c7e9c0', 200, '#74c476', 500, '#31a354', 1000, '#006d2c', 5000, '#00441b'],
'#cccccc'
],
'fill-opacity': 0.65
}
const safeSetStyle = (m: maplibregl.Map, style: any) => {
const terrain = m.getTerrain();
if (terrain) {
m.setTerrain(null);
}
m.setStyle(style);
if (terrain) {
m.once('style.load', () => {
setTimeout(() => {
try { m.setTerrain(terrain); } catch(e) {}
}, 50);
});
}
}
};
export interface GridSearchMapProps {
bboxesFeatureCollection: any;
@ -124,7 +108,7 @@ export function GridSearchMap({
if (!m || !isMapLoaded) return;
if (!posterMode) {
m.setStyle(MAP_STYLES[mapStyleKey]);
safeSetStyle(m, MAP_STYLES[mapStyleKey]);
return;
}
@ -168,8 +152,7 @@ export function GridSearchMap({
}
return layer;
});
m.setStyle(style);
safeSetStyle(m, style);
} catch (e) {
console.error("Failed to apply poster theme", e);
}
@ -177,32 +160,7 @@ export function GridSearchMap({
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;
@ -224,8 +182,6 @@ export function GridSearchMap({
m.on('load', () => {
setIsMapLoaded(true);
onMapReady?.(m);
addMapSources(m, { type: 'FeatureCollection', features: [] }, { type: 'FeatureCollection', features: [] });
addMapLayers(m, false, mapStyleRef.current === 'dark');
setTimeout(() => m.resize(), 100);
});
@ -235,101 +191,7 @@ export function GridSearchMap({
m.addSource('terrainSource', terrainDef);
if (!m.getSource('hillshadeSource')) m.addSource('hillshadeSource', terrainDef);
}
addMapSources(m, bboxesFeatureCollection || { type: 'FeatureCollection', features: [] }, polygonsFeatureCollection || { type: 'FeatureCollection', features: [] });
addMapLayers(m, showDensity, mapStyleRef.current === 'dark');
const emptyFc = { type: 'FeatureCollection', features: [] } as any;
// Add BBOX Source
if (!m.getSource('bboxes')) {
m.addSource('bboxes', { type: 'geojson', data: emptyFc });
m.addLayer({
id: 'gridsearch-bboxes',
type: 'fill',
source: 'bboxes',
paint: {
'fill-color': '#088',
'fill-opacity': 0.1,
'fill-outline-color': '#00f'
}
});
}
// 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({
id: 'simulator-grid-fill',
type: 'fill',
source: 'simulator-grid',
paint: {
'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)'
],
'fill-outline-color': isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'
}
});
m.addSource('simulator-path', { type: 'geojson', data: emptyFc });
m.addLayer({
id: 'simulator-path-line',
type: 'line',
source: 'simulator-path',
paint: {
'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-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': ['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
}
});
}
});
const cleanupListeners = setupMapListeners(m);
@ -342,67 +204,7 @@ export function GridSearchMap({
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Effect to update data
useEffect(() => {
if (!map.current || !isMapLoaded) return;
const m = map.current;
const emptyFc = { type: 'FeatureCollection', features: [] } as any;
(m.getSource('grid-bboxes') as maplibregl.GeoJSONSource)?.setData(bboxesFeatureCollection);
(m.getSource('grid-polygons') as maplibregl.GeoJSONSource)?.setData(polygonsFeatureCollection);
if (m.getSource('bboxes')) (m.getSource('bboxes') as maplibregl.GeoJSONSource).setData(bboxesFeatureCollection);
if (m.getSource('polygons')) (m.getSource('polygons') as maplibregl.GeoJSONSource).setData(polygonsFeatureCollection);
if (m.getSource('simulator-grid')) (m.getSource('simulator-grid') as maplibregl.GeoJSONSource).setData(simulatorData || emptyFc);
if (m.getSource('simulator-path')) (m.getSource('simulator-path') as maplibregl.GeoJSONSource).setData(simulatorPath || emptyFc);
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;
const m = map.current;
if (m.getLayer('density-fill'))
m.setLayoutProperty('density-fill', 'visibility', showDensity ? 'visible' : 'none');
if (m.getLayer('polygons-fill'))
m.setLayoutProperty('polygons-fill', 'visibility', showDensity ? 'none' : 'visible');
if (m.getLayer('polygons-line'))
m.setLayoutProperty('polygons-line', 'visibility', showDensity ? 'none' : 'visible');
}, [showDensity, isMapLoaded]);
const hasAutoCentered = useRef(false);
@ -457,6 +259,27 @@ export function GridSearchMap({
<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" />
{isMapLoaded && map.current && (
<>
<SimulatorLayers
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
simulatorData={simulatorData}
simulatorPath={simulatorPath}
simulatorScanner={simulatorScanner}
/>
<RegionLayers
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
bboxesFeatureCollection={bboxesFeatureCollection}
polygonsFeatureCollection={polygonsFeatureCollection}
pickerPolygons={pickerPolygons}
showDensity={showDensity}
showCenters={showCenters}
/>
</>
)}
{overlayBottomLeft && (
<div className="absolute bottom-4 left-4 z-10 pointer-events-auto">
{overlayBottomLeft}
@ -504,22 +327,13 @@ export function GridSearchMap({
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>
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={onToggleDensity}
showCenters={showCenters}
onToggleCenters={onToggleCenters}
/>
<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`}

View File

@ -1,914 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import * as turf from '@turf/turf';
import { Loader2, Play, Pause, Square, FastForward, Eye, Bug, Copy, Download, Upload } from 'lucide-react';
import { generateGridSearchCells } from '../utils/grid-generator';
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 GridSimulatorSettings {
gridMode: 'hex' | 'square' | 'admin' | 'centers';
pathOrder: 'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest';
groupByRegion: boolean;
cellSize: number;
cellOverlap: number;
centroidOverlap: number;
ghsFilterMode: 'AND' | 'OR';
maxCellsLimit: number;
maxElevation: number;
minDensity: number;
minGhsPop: number;
minGhsBuilt: number;
enableElevation: boolean;
enableDensity: boolean;
enableGhsPop: boolean;
enableGhsBuilt: boolean;
allowMissingGhs: boolean;
bypassFilters: boolean;
}
function DeferredNumberInput({ value, onChange, ...props }: any) {
const [local, setLocal] = useState(value);
useEffect(() => { setLocal(value); }, [value]);
const handleCommit = () => {
const num = Number(local);
if (!isNaN(num) && num !== value) onChange(num);
};
return (
<input
type="number"
{...props}
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={handleCommit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
)
}
function DeferredRangeSlider({ value, onChange, ...props }: any) {
const [local, setLocal] = useState(value);
useEffect(() => { setLocal(value); }, [value]);
const handleCommit = () => {
const num = Number(local);
if (!isNaN(num) && num !== value) onChange(num);
};
return (
<input
type="range"
{...props}
value={local}
onChange={(e) => setLocal(Number(e.target.value))}
onMouseUp={handleCommit}
onTouchEnd={handleCommit}
onBlur={handleCommit}
/>
)
}
interface GridSearchSimulatorProps {
pickerRegions: any[];
pickerPolygons: any[];
onFilterCell?: (cell: any) => boolean;
setSimulatorData: (data: any) => void;
setSimulatorPath: (data: any) => void;
setSimulatorScanner: (data: any) => void;
}
export function GridSearchSimulator({
pickerRegions, pickerPolygons, onFilterCell,
setSimulatorData, setSimulatorPath, setSimulatorScanner
}: GridSearchSimulatorProps) {
const [gridCells, setGridCells] = useState<any[]>([]);
const [progressIndex, setProgressIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(0.5);
const [skippedTotal, setSkippedTotal] = useState(0);
// Config
const [gridMode, setGridMode] = useLocalStorage<'hex' | 'square' | 'admin' | 'centers'>('pm_gridMode', 'hex');
const [pathOrder, setPathOrder] = useLocalStorage<'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'>('pm_pathOrder', 'snake');
const [groupByRegion, setGroupByRegion] = useLocalStorage<boolean>('pm_groupByRegion', true);
const [cellSize, setCellSize] = useLocalStorage<number>('pm_cellSize', 2.5);
const [cellOverlap, setCellOverlap] = useLocalStorage<number>('pm_cellOverlap', 0);
const [centroidOverlap, setCentroidOverlap] = useLocalStorage<number>('pm_centroidOverlap', 50);
const [ghsFilterMode, setGhsFilterMode] = useLocalStorage<'AND' | 'OR'>('pm_ghsFilterMode', 'AND');
const [maxCellsLimit, setMaxCellsLimit] = useLocalStorage<number>('pm_maxCellsLimit', 15000);
const [maxElevation, setMaxElevation] = useLocalStorage<number>('pm_maxElevation', 700);
const [minDensity, setMinDensity] = useLocalStorage<number>('pm_minDensity', 10);
const [minGhsPop, setMinGhsPop] = useLocalStorage<number>('pm_minGhsPop', 0);
const [minGhsBuilt, setMinGhsBuilt] = useLocalStorage<number>('pm_minGhsBuilt', 0);
const [enableElevation, setEnableElevation] = useLocalStorage<boolean>('pm_enElev', false);
const [enableDensity, setEnableDensity] = useLocalStorage<boolean>('pm_enDens', false);
const [enableGhsPop, setEnableGhsPop] = useLocalStorage<boolean>('pm_enPop', false);
const [enableGhsBuilt, setEnableGhsBuilt] = useLocalStorage<boolean>('pm_enBuilt', false);
const [allowMissingGhs, setAllowMissingGhs] = useLocalStorage<boolean>('pm_allowMissGhs', false);
const [bypassFilters, setBypassFilters] = useLocalStorage<boolean>('pm_bypassFilters', false);
const [isCalculating, setIsCalculating] = useState(false);
const [calcStats, setCalcStats] = useState({ current: 0, total: 0, valid: 0 });
const skippedCellsRef = useRef<any[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const stopRequestedRef = useRef<boolean>(false);
const reqRef = useRef<number>();
const lastTickRef = useRef<number>(0);
const globalProcessedHopsRef = useRef<any[]>([]);
const [ghsBounds, setGhsBounds] = useState({ minPop: 0, maxPop: 1000000, minBuilt: 0, maxBuilt: 1000000 });
useEffect(() => {
if (!pickerPolygons || pickerPolygons.length === 0) return;
let minPop = Infinity;
let maxPop = -Infinity;
let minBuilt = Infinity;
let maxBuilt = -Infinity;
pickerPolygons.forEach(fc => {
if (fc && fc.features) {
fc.features.forEach((f: any) => {
const raw = f.properties || {};
const pop = raw.ghsPopulation;
const built = raw.ghsBuiltWeight;
if (typeof pop === 'number') {
if (pop < minPop) minPop = pop;
if (pop > maxPop) maxPop = pop;
}
if (typeof built === 'number') {
if (built < minBuilt) minBuilt = built;
if (built > maxBuilt) maxBuilt = built;
}
});
}
});
if (minPop !== Infinity) {
setGhsBounds({
minPop, maxPop, minBuilt: minBuilt === Infinity ? 0 : minBuilt, maxBuilt: maxBuilt === -Infinity ? 1000000 : maxBuilt
});
}
}, [pickerPolygons]);
// Removed auto-adjust cell size so user preference is preserved across sessions
// --- IMPORT / EXPORT / COPY SETTINGS ---
const getFinalHopList = () => {
return gridCells
.filter(c => c.properties.sim_status !== 'skipped')
.map((c, i) => {
const pt = turf.centroid(c).geometry.coordinates;
return {
step: i + 1,
lng: Number(pt[0].toFixed(6)),
lat: Number(pt[1].toFixed(6)),
radius_km: c.properties.search_radius_km ? Number(c.properties.search_radius_km.toFixed(2)) : undefined
};
});
};
const handleCopyWaypoints = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(getFinalHopList(), null, 2));
} catch (err) {
console.error('Failed to copy waypoints', err);
}
};
const handleExportWaypoints = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(getFinalHopList(), null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", `grid-waypoints-${new Date().toISOString().slice(0, 10)}.json`);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const getCurrentSettings = (): GridSimulatorSettings => ({
gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap,
ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt,
enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters
});
const handleCopySettings = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(getCurrentSettings(), null, 2));
} catch (err) {
console.error('Failed to copy settings', err);
}
};
const handleExportSettings = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(getCurrentSettings(), null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", `grid-simulator-settings-${new Date().toISOString().slice(0, 10)}.json`);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const handleImportSettings = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string) as Partial<GridSimulatorSettings>;
if (json.gridMode) setGridMode(json.gridMode);
if (json.pathOrder) setPathOrder(json.pathOrder);
if (json.groupByRegion !== undefined) setGroupByRegion(json.groupByRegion);
if (json.cellSize !== undefined) setCellSize(json.cellSize);
if (json.cellOverlap !== undefined) setCellOverlap(json.cellOverlap);
if (json.centroidOverlap !== undefined) setCentroidOverlap(json.centroidOverlap);
if (json.ghsFilterMode) setGhsFilterMode(json.ghsFilterMode);
if (json.maxCellsLimit !== undefined) setMaxCellsLimit(json.maxCellsLimit);
if (json.maxElevation !== undefined) setMaxElevation(json.maxElevation);
if (json.minDensity !== undefined) setMinDensity(json.minDensity);
if (json.minGhsPop !== undefined) setMinGhsPop(json.minGhsPop);
if (json.minGhsBuilt !== undefined) setMinGhsBuilt(json.minGhsBuilt);
if (json.enableElevation !== undefined) setEnableElevation(json.enableElevation);
if (json.enableDensity !== undefined) setEnableDensity(json.enableDensity);
if (json.enableGhsPop !== undefined) setEnableGhsPop(json.enableGhsPop);
if (json.enableGhsBuilt !== undefined) setEnableGhsBuilt(json.enableGhsBuilt);
if (json.allowMissingGhs !== undefined) setAllowMissingGhs(json.allowMissingGhs);
if (json.bypassFilters !== undefined) setBypassFilters(json.bypassFilters);
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) {
console.error("Failed to parse settings JSON", err);
alert("Failed to parse settings JSON. Is it a valid file?");
}
};
reader.readAsText(file);
};
// --- GRID GENERATION ---
const computeGrid = useCallback(async (autoPlay: boolean | 'preview' = false) => {
if (!pickerRegions || pickerRegions.length === 0 || !pickerPolygons) {
setGridCells([]);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
return;
}
// 1. Combine regions into a single Multipolygon FeatureCollection
const features: any[] = [];
pickerPolygons.forEach(fc => {
if (fc && fc.features) {
features.push(...fc.features);
}
});
if (features.length === 0) return;
setIsCalculating(true);
stopRequestedRef.current = false;
setCalcStats({ current: 0, total: 0, valid: 0 });
// Yield to the event loop so React can render the 'Calculating...' overlay and Stop button
// before the heavy synchronous Turf operations begin.
await new Promise(r => setTimeout(r, 10));
try {
const result = await generateGridSearchCells({
features, gridMode, cellSize, pathOrder, groupByRegion, onFilterCell,
maxElevation: enableElevation ? maxElevation : 0,
minDensity: enableDensity ? minDensity : 0,
minGhsPop: enableGhsPop ? minGhsPop : 0,
minGhsBuilt: enableGhsBuilt ? minGhsBuilt : 0,
allowMissingGhs,
bypassFilters,
cellOverlap: cellOverlap / 100, // convert percentage to scalar
centroidOverlap: centroidOverlap / 100,
ghsFilterMode,
maxCellsLimit,
skipPolygons: globalProcessedHopsRef.current
}, async (stats) => {
setCalcStats({ current: stats.current, total: stats.total, valid: stats.validCells.length });
if (stopRequestedRef.current) {
return false;
}
// Yield to event loop to allow UI updates
await new Promise(r => setTimeout(r, 0));
return true;
});
if (result.error) {
alert(result.error);
setIsCalculating(false);
return;
}
setGridCells(result.validCells);
skippedCellsRef.current = result.skippedCells || [];
setSkippedTotal(result.skippedCells ? result.skippedCells.length : 0);
if (autoPlay === 'preview') {
setProgressIndex(result.validCells.length);
setIsPlaying(false);
} else {
setProgressIndex(0);
setIsPlaying(stopRequestedRef.current ? false : autoPlay);
}
// Update Map Layers
setSimulatorData(turf.featureCollection(result.validCells));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
setIsCalculating(false);
} catch (err) {
console.error("Turf Error:", err);
alert("An error occurred during grid generation.");
setIsCalculating(false);
}
}, [pickerRegions, pickerPolygons, gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, onFilterCell, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters]);
const activeSettingsRef = useRef({ gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters });
useEffect(() => {
const prev = activeSettingsRef.current;
let changed = false;
if (prev.gridMode !== gridMode) changed = true;
if (prev.cellSize !== cellSize) changed = true;
if (prev.cellOverlap !== cellOverlap) changed = true;
if (prev.centroidOverlap !== centroidOverlap) changed = true;
if (prev.maxElevation !== maxElevation) changed = true;
if (prev.minDensity !== minDensity) changed = true;
if (prev.minGhsPop !== minGhsPop) changed = true;
if (prev.minGhsBuilt !== minGhsBuilt) changed = true;
if (prev.ghsFilterMode !== ghsFilterMode) changed = true;
if (prev.pathOrder !== pathOrder) changed = true;
if (prev.groupByRegion !== groupByRegion) changed = true;
if (prev.maxCellsLimit !== maxCellsLimit) changed = true;
if (prev.enableElevation !== enableElevation) changed = true;
if (prev.enableDensity !== enableDensity) changed = true;
if (prev.enableGhsPop !== enableGhsPop) changed = true;
if (prev.enableGhsBuilt !== enableGhsBuilt) changed = true;
if (prev.allowMissingGhs !== allowMissingGhs) changed = true;
if (prev.bypassFilters !== bypassFilters) changed = true;
if (changed) {
globalProcessedHopsRef.current = [];
activeSettingsRef.current = { gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters };
// Auto preview if we have polygons and settings actually changed
if (pickerPolygons && pickerPolygons.length > 0) {
// Wrap in setTimeout to avoid React warnings about rendering while updating another component
setTimeout(() => computeGrid('preview'), 0);
}
}
}, [gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, pickerPolygons, computeGrid, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters]);
const prevRegionsRef = useRef<string>('');
useEffect(() => {
const currentRegions = (pickerRegions || []).map(r => r.gid).sort().join(',');
if (prevRegionsRef.current !== currentRegions) {
prevRegionsRef.current = currentRegions;
setGridCells([]);
setIsPlaying(false);
setProgressIndex(0);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
}
}, [pickerRegions, setSimulatorData, setSimulatorPath, setSimulatorScanner]);
const handleClear = useCallback(() => {
setGridCells([]);
setIsPlaying(false);
setProgressIndex(0);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
globalProcessedHopsRef.current = [];
}, [setSimulatorData, setSimulatorPath, setSimulatorScanner]);
// Animation Loop
useEffect(() => {
if (!isPlaying || progressIndex >= gridCells.length) {
if (progressIndex >= gridCells.length && isPlaying) setIsPlaying(false);
return;
}
const tick = (time: number) => {
if (time - lastTickRef.current > (1000 / (10 * speed))) {
// speed 1 = 10 cells/sec
setProgressIndex(prev => {
if (prev < gridCells.length) {
const cell = gridCells[prev];
if (cell.properties.sim_status !== 'skipped') {
globalProcessedHopsRef.current.push(cell);
}
}
const next = prev + 1;
return next > gridCells.length ? gridCells.length : next;
});
lastTickRef.current = time;
}
reqRef.current = requestAnimationFrame(tick);
};
reqRef.current = requestAnimationFrame(tick);
return () => {
if (reqRef.current) cancelAnimationFrame(reqRef.current);
};
}, [isPlaying, progressIndex, gridCells.length, speed]);
// Sync State to GeoJSON
useEffect(() => {
if (gridCells.length === 0) return;
// 1. Update Grid Colors
const updatedCells = gridCells.map((cell, i) => {
if (cell.properties.sim_status === 'skipped') return cell;
return {
...cell,
properties: {
...cell.properties,
sim_status: i < progressIndex ? 'processed' : 'pending'
}
};
});
setSimulatorData(turf.featureCollection(updatedCells));
// 2. Build Trajectory Path up to progressIndex
const pathCoords = gridCells
.slice(0, progressIndex)
.filter(c => c.properties.sim_status !== 'skipped')
.map(c => turf.centroid(c).geometry.coordinates);
if (pathCoords.length > 1) {
setSimulatorPath(turf.featureCollection([turf.lineString(pathCoords)]));
} else {
setSimulatorPath(turf.featureCollection([]));
}
// 3. Update Scanner Head
const activeCells = gridCells.filter(c => c.properties.sim_status !== 'skipped');
const currentIndex = gridCells.slice(0, progressIndex).filter(c => c.properties.sim_status !== 'skipped').length;
if (currentIndex > 0 && currentIndex <= activeCells.length) {
const currentCell = activeCells[currentIndex - 1];
const centroid = turf.centroid(currentCell);
let angle = 0;
if (currentIndex > 1) {
const prevCell = activeCells[currentIndex - 2];
angle = turf.bearing(turf.centroid(prevCell), centroid);
} else if (currentIndex === 1 && activeCells.length > 1) {
const nextCell = activeCells[1];
angle = turf.bearing(centroid, turf.centroid(nextCell));
}
centroid.properties = {
bearing: angle,
icon_state: (progressIndex % 2 === 0) ? 'pacman-open' : 'pacman-closed'
};
setSimulatorScanner(turf.featureCollection([centroid]));
} else {
setSimulatorScanner(turf.featureCollection([]));
}
}, [progressIndex, gridCells, setSimulatorData, setSimulatorPath, setSimulatorScanner]);
const processedCount = progressIndex;
const skippedCount = skippedTotal;
const validCount = gridCells.length;
if (!pickerRegions || pickerRegions.length === 0) return null;
return (
<div className="relative overflow-hidden pt-2 flex flex-col">
<div className="space-y-4 pb-4 mb-4 border-b dark:border-gray-700">
{/* Controls */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-1">
<button
onClick={() => {
if (gridCells.length === 0) {
computeGrid(true);
} else {
if (!isPlaying && progressIndex >= gridCells.length) {
setProgressIndex(0);
}
setIsPlaying(!isPlaying);
}
}}
disabled={isCalculating || !pickerPolygons || pickerPolygons.length === 0}
className={`p-2 rounded ${isPlaying ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<button
onClick={handleClear}
disabled={isCalculating}
className="p-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
title="Clear Grid"
>
<Square className="w-4 h-4" />
</button>
<button
onClick={() => {
setIsPlaying(false);
if (gridCells.length === 0) {
computeGrid('preview');
} else {
setProgressIndex(gridCells.length);
}
}}
disabled={isCalculating || !pickerPolygons || pickerPolygons.length === 0}
className="p-2 rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title="Preview all cells"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1">Preview</span>
</button>
<button
onClick={() => {
console.log('--- GRID SEARCH DEBUG DUMP ---');
console.log(`Valid Cells: ${gridCells.length}`, gridCells.slice(0, 5).map(c => c.properties));
const skipped = skippedCellsRef.current;
console.log(`Skipped Cells: ${skipped.length}`);
const skippedByReason = skipped.reduce((acc, cell) => {
const r = cell.properties?._reason || cell.properties?.sim_skip_reason || 'Unknown';
acc[r] = (acc[r] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Skipped Breakdown:', skippedByReason);
console.log('Skipped Sample (first 5):', skipped.slice(0, 5).map(c => c.properties));
console.log('------------------------------');
}}
disabled={isCalculating}
className="p-2 rounded bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title="Print Debug Info to Console"
>
<Bug className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1">Debug</span>
</button>
</div>
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded p-1 text-xs">
<FastForward className="w-3 h-3 text-gray-500 mr-1" />
{[0.5, 1, 5, 10, 50].map(s => (
<button key={s} onClick={() => setSpeed(s)} className={`px-2 py-1 rounded ${speed === s ? 'bg-white dark:bg-gray-600 shadow-sm font-bold' : ''}`}>
{s}x
</button>
))}
</div>
</div>
{/* Progress Stats */}
<div>
<div className="flex justify-between text-xs mb-1 text-gray-600 dark:text-gray-400">
<span>Scanning...</span>
<span className="font-mono">{progressIndex} / {gridCells.length}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div className="bg-blue-500 h-2 transition-all duration-75" style={{ width: `${(progressIndex / gridCells.length) * 100}%` }}></div>
</div>
<div className="grid grid-cols-3 gap-2 mt-3 text-center text-xs h-full">
<div className="bg-gray-50 dark:bg-gray-700 p-2 rounded flex flex-col justify-between h-full">
<div>
<span className="block text-gray-500 font-semibold mb-1">Target Calls</span>
<span className="font-mono text-gray-800 dark:text-gray-200">{validCount}</span>
</div>
{gridCells.length > 0 && (
<div className="flex gap-3 mt-2 pt-2 border-t border-gray-200 dark:border-gray-600 justify-center">
<button onClick={handleCopyWaypoints} className="text-gray-400 hover:text-sky-600 focus:outline-none flex items-center gap-1" title="Copy Waypoints (GeoJSON)">
<Copy className="w-3.5 h-3.5" />
</button>
<button onClick={handleExportWaypoints} className="text-gray-400 hover:text-sky-600 focus:outline-none flex items-center gap-1" title="Download Waypoints (GeoJSON)">
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-2 rounded flex flex-col justify-center h-full">
<span className="block text-red-500 font-semibold mb-1">Skipped Calls</span>
<span className="font-mono text-red-800 dark:text-red-300">{skippedCount}</span>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-2 rounded flex flex-col justify-center h-full">
<span className="block text-green-600 font-semibold mb-1">Simulated Processed</span>
<span className="font-mono text-green-800 dark:text-green-300">{processedCount}</span>
</div>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center bg-gray-50 dark:bg-gray-800/50 p-2 rounded border border-gray-100 dark:border-gray-700/50">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Settings
</h4>
<div className="flex gap-2">
<button onClick={handleCopySettings} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title="Copy settings to clipboard">
<Copy className="w-4 h-4" />
</button>
<button onClick={handleExportSettings} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title="Download settings JSON">
<Download className="w-4 h-4" />
</button>
<button onClick={() => fileInputRef.current?.click()} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title="Import settings JSON">
<Upload className="w-4 h-4" />
</button>
<input type="file" ref={fileInputRef} className="hidden" accept="application/json" onChange={handleImportSettings} />
</div>
</div>
<div className="flex flex-col gap-3 mb-3 mt-2">
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2">Grid Simulation Mode</label>
<div className="flex gap-2">
<button
onClick={() => setGridMode('hex')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${gridMode === 'hex' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Hex Geometric Grid
</button>
<button
onClick={() => setGridMode('square')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${gridMode === 'square' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Square Geometric Grid
</button>
<button
onClick={() => setGridMode('admin')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${gridMode === 'admin' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Native GADM Regions
</button>
<button
onClick={() => setGridMode('centers')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${gridMode === 'centers' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
GHS Centers
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2">Scan Trajectory</label>
<div className="flex gap-2 mb-2">
<button
onClick={() => setPathOrder('zigzag')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${pathOrder === 'zigzag' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Zig-Zag
</button>
<button
onClick={() => setPathOrder('snake')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${pathOrder === 'snake' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Snake
</button>
<button
onClick={() => setPathOrder('spiral-out')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${pathOrder === 'spiral-out' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Spiral Out
</button>
<button
onClick={() => setPathOrder('spiral-in')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${pathOrder === 'spiral-in' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Spiral In
</button>
<button
onClick={() => setPathOrder('shortest')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${pathOrder === 'shortest' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
Shortest
</button>
</div>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mt-2 cursor-pointer">
<input
type="checkbox"
checked={groupByRegion}
onChange={e => setGroupByRegion(e.target.checked)}
className="mr-2"
/>
Constrain process sequentially per boundary
</label>
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t dark:border-gray-700">
<div className={enableElevation ? '' : 'opacity-40'}>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<input type="checkbox" checked={enableElevation} onChange={(e) => setEnableElevation(e.target.checked)} className="mr-2" />
Max Elevation (m)
</label>
<div className={enableElevation ? '' : 'pointer-events-none select-none'}>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={maxElevation}
onChange={(num: number) => setMaxElevation(num)}
/>
<span className="text-[10px] text-gray-500">Drop &gt; {maxElevation}m</span>
</div>
</div>
<div className={enableDensity ? '' : 'opacity-40'}>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<input type="checkbox" checked={enableDensity} onChange={(e) => setEnableDensity(e.target.checked)} className="mr-2" />
Min Density (p/km²)
</label>
<div className={enableDensity ? '' : 'pointer-events-none select-none'}>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={minDensity}
onChange={(num: number) => setMinDensity(num)}
/>
<span className="text-[10px] text-gray-500">Drop &lt; {minDensity}</span>
</div>
</div>
<div className="col-span-2 flex flex-col border-b pb-2 mb-2 mt-2 dark:border-gray-700 gap-2">
<div>
<h3 className="text-xs font-bold text-gray-800 dark:text-gray-200">GHS Data Thresholds</h3>
<p className="text-[10px] text-gray-500">Logic when filters are active.</p>
</div>
<div className="flex flex-col gap-3 items-start mt-2">
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title="If enabled, it ignores ALL density, elevation, geometry overlap, and custom filters. Will push all raw items">
<input type="checkbox" checked={bypassFilters} onChange={e => setBypassFilters(e.target.checked)} className="mr-1" />
Bypass All Filters
</label>
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title="If a region is physically missing GHS raster data, allow it to pass instead of auto-failing">
<input type="checkbox" checked={allowMissingGhs} onChange={e => setAllowMissingGhs(e.target.checked)} className="mr-1" />
Allow Missing Data Gaps
</label>
<div className="flex items-center gap-2 cursor-pointer" onClick={() => setGhsFilterMode(ghsFilterMode === 'AND' ? 'OR' : 'AND')}>
<span className={`text-[10px] font-semibold ${ghsFilterMode === 'AND' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>AND</span>
<button
type="button"
className="relative inline-flex h-4 w-8 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none"
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${ghsFilterMode === 'OR' ? 'translate-x-4' : 'translate-x-1'}`} />
</button>
<span className={`text-[10px] font-semibold ${ghsFilterMode === 'OR' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>OR</span>
</div>
</div>
</div>
<div className={`col-span-2 ${enableGhsPop ? '' : 'opacity-50'}`}>
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<span className="flex items-center">
<input type="checkbox" checked={enableGhsPop} onChange={(e) => setEnableGhsPop(e.target.checked)} className="mr-2" />
Min GHS Population
</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">{minGhsPop.toLocaleString()}</span>
</label>
<div className={`flex gap-2 items-center ${enableGhsPop ? '' : 'pointer-events-none select-none'}`}>
<span className="text-[10px] text-gray-400">{ghsBounds.minPop > 0 ? ghsBounds.minPop.toLocaleString() : '0'}</span>
<DeferredRangeSlider
min={Math.floor(ghsBounds.minPop)}
max={Math.ceil(ghsBounds.maxPop)}
step={parseInt(((ghsBounds.maxPop - ghsBounds.minPop)/100).toFixed(0)) || 1}
value={minGhsPop}
onChange={(num: number) => setMinGhsPop(num)}
className="flex-1"
/>
<span className="text-[10px] text-gray-400">{ghsBounds.maxPop > 0 ? ghsBounds.maxPop.toLocaleString() : 'Max'}</span>
</div>
</div>
<div className={`col-span-2 ${enableGhsBuilt ? '' : 'opacity-50'}`}>
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<span className="flex items-center">
<input type="checkbox" checked={enableGhsBuilt} onChange={(e) => setEnableGhsBuilt(e.target.checked)} className="mr-2" />
Min GHS Built Area
</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">{minGhsBuilt.toLocaleString()}</span>
</label>
<div className={`flex gap-2 items-center ${enableGhsBuilt ? '' : 'pointer-events-none select-none'}`}>
<span className="text-[10px] text-gray-400">{ghsBounds.minBuilt > 0 ? ghsBounds.minBuilt.toLocaleString() : '0'}</span>
<DeferredRangeSlider
min={Math.floor(ghsBounds.minBuilt)}
max={Math.ceil(ghsBounds.maxBuilt)}
step={parseInt(((ghsBounds.maxBuilt - ghsBounds.minBuilt)/100).toFixed(0)) || 1}
value={minGhsBuilt}
onChange={(num: number) => setMinGhsBuilt(num)}
className="flex-1"
/>
<span className="text-[10px] text-gray-400">{ghsBounds.maxBuilt > 0 ? ghsBounds.maxBuilt.toLocaleString() : 'Max'}</span>
</div>
</div>
</div>
</div>
<div className={`space-y-3 mt-3 pt-3 border-t dark:border-gray-700 transition-opacity ${gridMode === 'admin' ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1">Cell Base Size (km)</label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={cellSize}
onChange={(num: number) => setCellSize(num)}
min={0.5}
step={0.5}
disabled={gridMode === 'admin'}
/>
</div>
{gridMode === 'centers' ? (
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1">Centroid Overlap Allowed %</label>
<DeferredRangeSlider
className="w-full"
value={centroidOverlap}
onChange={(num: number) => setCentroidOverlap(num)}
min={0}
max={100}
step={1}
title="0% means centroids must be at least 1 cell-size apart. 100% allows exact duplicates."
/>
<div className="flex justify-between text-[10px] text-gray-500">
<span>No Overlap (Dist)</span>
<span>Full Overlap</span>
</div>
</div>
) : (
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1">Overlap %</label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={cellOverlap}
onChange={(num: number) => setCellOverlap(num)}
min={0}
max={100}
step={1}
title="Increase to balloon the geometric cells and ensure no places are missed at the seams."
disabled={gridMode === 'admin'}
/>
</div>
)}
</div>
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1">Grid Generation Limit</label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={maxCellsLimit}
onChange={(num: number) => setMaxCellsLimit(num)}
step={500}
disabled={gridMode === 'admin'}
/>
</div>
</div>
</div>
</div>
{isCalculating && (
<div className="absolute top-0 left-0 right-0 bottom-0 bg-white/50 dark:bg-gray-900/50 flex flex-col items-center justify-center z-10 backdrop-blur-[1px]">
<div className="bg-white dark:bg-gray-800 shadow-lg border dark:border-gray-700 rounded-md p-4 flex flex-col items-center text-sm font-medium text-blue-600 dark:text-blue-400 max-w-xs text-center">
<div className="flex items-center mb-3">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span>
Calculating...
{calcStats.total > 0 && <span className="block text-xs font-normal text-gray-500 mt-1">{calcStats.current} / {calcStats.total} cells</span>}
</span>
</div>
<button
onClick={() => { stopRequestedRef.current = true; }}
className="w-full bg-red-600 hover:bg-red-700 text-white py-1.5 px-3 rounded transition-colors text-xs font-semibold"
>
Stop
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Users, MapPin } from 'lucide-react';
export interface MapLayerTogglesProps {
showDensity?: boolean;
onToggleDensity?: (visible: boolean) => void;
showCenters?: boolean;
onToggleCenters?: (visible: boolean) => void;
}
export function MapLayerToggles({
showDensity,
onToggleDensity,
showCenters,
onToggleCenters
}: MapLayerTogglesProps) {
return (
<>
{onToggleDensity && (
<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>
)}
{onToggleCenters && (
<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>
)}
</>
);
}

View File

@ -0,0 +1,211 @@
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
export interface RegionLayersProps {
map: maplibregl.Map | null;
isDarkStyle: boolean;
bboxesFeatureCollection?: any;
polygonsFeatureCollection?: any;
pickerPolygons?: any[];
showDensity?: boolean;
showCenters?: boolean;
}
const emptyFc = { type: 'FeatureCollection', features: [] };
export function RegionLayers({
map,
isDarkStyle,
bboxesFeatureCollection,
polygonsFeatureCollection,
pickerPolygons,
showDensity = false,
showCenters = false
}: RegionLayersProps) {
const isDarkStyleRef = useRef(isDarkStyle);
isDarkStyleRef.current = isDarkStyle;
const showDensityRef = useRef(showDensity);
showDensityRef.current = showDensity;
const bboxesCollectionRef = useRef(bboxesFeatureCollection);
bboxesCollectionRef.current = bboxesFeatureCollection;
const polygonsCollectionRef = useRef(polygonsFeatureCollection);
polygonsCollectionRef.current = polygonsFeatureCollection;
const ghsCentersDataRef = useRef<any>(emptyFc);
// Add Sources and Layers
useEffect(() => {
if (!map) return;
const setupMapLayers = () => {
if (!map.getStyle()) return;
const currentIsDark = isDarkStyleRef.current;
const currentShowDensity = showDensityRef.current;
// Add Sources
if (!map.getSource('grid-bboxes')) map.addSource('grid-bboxes', { type: 'geojson', data: bboxesCollectionRef.current || emptyFc as any });
if (!map.getSource('grid-polygons')) map.addSource('grid-polygons', { type: 'geojson', data: polygonsCollectionRef.current || emptyFc as any });
if (!map.getSource('ghs-centers')) map.addSource('ghs-centers', { type: 'geojson', data: ghsCentersDataRef.current || emptyFc as any });
// Add Layers
if (!map.getLayer('polygons-fill')) {
map.addLayer({
id: 'polygons-fill', type: 'fill', source: 'grid-polygons',
paint: { 'fill-color': currentIsDark ? '#3b82f6' : '#2563eb', 'fill-opacity': currentIsDark ? 0.3 : 0.5 },
layout: { visibility: currentShowDensity ? 'none' : 'visible' }
});
}
if (!map.getLayer('polygons-line')) {
map.addLayer({
id: 'polygons-line', type: 'line', source: 'grid-polygons',
paint: { 'line-color': currentIsDark ? '#2563eb' : '#1d4ed8', 'line-width': currentIsDark ? 2 : 3 },
layout: { visibility: currentShowDensity ? 'none' : 'visible' }
});
}
if (!map.getLayer('bboxes-fill')) {
map.addLayer({
id: 'bboxes-fill', type: 'fill', source: 'grid-bboxes',
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0 }
});
}
if (!map.getLayer('bboxes-line')) {
map.addLayer({
id: 'bboxes-line', type: 'line', source: 'grid-bboxes',
paint: { 'line-color': '#d97706', 'line-width': 2, 'line-dasharray': [2, 2] }
});
}
if (!map.getLayer('density-fill')) {
map.addLayer({
id: 'density-fill', type: 'fill', source: 'grid-bboxes',
layout: { visibility: currentShowDensity ? 'visible' : 'none' },
paint: {
'fill-color': [
'case',
['all', ['has', 'population'], ['has', 'areaSqKm'], ['>', ['get', 'areaSqKm'], 0]],
['interpolate', ['linear'], ['/', ['get', 'population'], ['get', 'areaSqKm']],
0, '#f7fcf5', 50, '#c7e9c0', 200, '#74c476', 500, '#31a354', 1000, '#006d2c', 5000, '#00441b'],
'#cccccc'
],
'fill-opacity': 0.65
}
});
}
if (!map.getLayer('ghs-centers-points')) {
map.addLayer({
id: 'ghs-centers-points',
type: 'circle',
source: 'ghs-centers',
paint: {
'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
}
});
}
};
if (map.getStyle()) setupMapLayers();
map.on('styledata', setupMapLayers);
map.on('style.load', setupMapLayers);
return () => {
map.off('styledata', setupMapLayers);
map.off('style.load', setupMapLayers);
if (map.getStyle()) {
if (map.getLayer('polygons-fill')) map.removeLayer('polygons-fill');
if (map.getLayer('polygons-line')) map.removeLayer('polygons-line');
if (map.getLayer('bboxes-fill')) map.removeLayer('bboxes-fill');
if (map.getLayer('bboxes-line')) map.removeLayer('bboxes-line');
if (map.getLayer('density-fill')) map.removeLayer('density-fill');
if (map.getLayer('ghs-centers-points')) map.removeLayer('ghs-centers-points');
if (map.getSource('grid-bboxes')) map.removeSource('grid-bboxes');
if (map.getSource('grid-polygons')) map.removeSource('grid-polygons');
if (map.getSource('ghs-centers')) map.removeSource('ghs-centers');
}
};
}, [map]);
// Update Styles
useEffect(() => {
if (!map || !map.getStyle()) return;
if (map.getLayer('polygons-fill')) {
map.setPaintProperty('polygons-fill', 'fill-color', isDarkStyle ? '#3b82f6' : '#2563eb');
map.setPaintProperty('polygons-fill', 'fill-opacity', isDarkStyle ? 0.3 : 0.5);
map.setLayoutProperty('polygons-fill', 'visibility', showDensity ? 'none' : 'visible');
}
if (map.getLayer('polygons-line')) {
map.setPaintProperty('polygons-line', 'line-color', isDarkStyle ? '#2563eb' : '#1d4ed8');
map.setPaintProperty('polygons-line', 'line-width', isDarkStyle ? 2 : 3);
map.setLayoutProperty('polygons-line', 'visibility', showDensity ? 'none' : 'visible');
}
if (map.getLayer('density-fill')) {
map.setLayoutProperty('density-fill', 'visibility', showDensity ? 'visible' : 'none');
}
}, [map, isDarkStyle, showDensity]);
// Update GHS Centers Source
useEffect(() => {
if (!map) return;
try {
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' }
});
}
});
}
});
const newData = { type: 'FeatureCollection', features };
ghsCentersDataRef.current = newData;
if (map.getSource('ghs-centers')) (map.getSource('ghs-centers') as maplibregl.GeoJSONSource).setData(newData as any);
} else {
ghsCentersDataRef.current = emptyFc;
if (map.getSource('ghs-centers')) (map.getSource('ghs-centers') as maplibregl.GeoJSONSource).setData(emptyFc as any);
}
} catch (e) {
console.warn("Could not set ghs-centers", e);
}
}, [map, showCenters, pickerPolygons]);
// Update Bboxes and Polygons Features
useEffect(() => {
if (!map) return;
try {
if (map.getSource('grid-bboxes')) (map.getSource('grid-bboxes') as maplibregl.GeoJSONSource).setData(bboxesFeatureCollection || emptyFc as any);
if (map.getSource('grid-polygons')) (map.getSource('grid-polygons') as maplibregl.GeoJSONSource).setData(polygonsFeatureCollection || emptyFc as any);
} catch (e) {
console.warn("Could not update grid-bboxes or grid-polygons", e);
}
}, [map, bboxesFeatureCollection, polygonsFeatureCollection]);
return null;
}

View File

@ -0,0 +1,152 @@
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
export interface SimulatorLayersProps {
map: maplibregl.Map | null;
isDarkStyle: boolean;
simulatorData?: any;
simulatorPath?: any;
simulatorScanner?: any;
}
const emptyFc = { type: 'FeatureCollection', features: [] };
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>`;
export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath, simulatorScanner }: SimulatorLayersProps) {
const isDarkStyleRef = useRef(isDarkStyle);
isDarkStyleRef.current = isDarkStyle;
const simulatorDataRef = useRef(simulatorData);
simulatorDataRef.current = simulatorData;
const simulatorPathRef = useRef(simulatorPath);
simulatorPathRef.current = simulatorPath;
const simulatorScannerRef = useRef(simulatorScanner);
simulatorScannerRef.current = simulatorScanner;
// Add Sources and Layers (Mount/Unmount only!)
useEffect(() => {
if (!map) return;
const setupMapLayers = () => {
if (!map.getStyle()) return;
const currentIsDark = isDarkStyleRef.current;
// Load Pacman Icons
const imgOpen = new Image(30, 30);
imgOpen.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanOpenSvg);
imgOpen.onload = () => { if (!map.hasImage('pacman-open')) map.addImage('pacman-open', imgOpen); };
const imgClosed = new Image(30, 30);
imgClosed.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanClosedSvg);
imgClosed.onload = () => { if (!map.hasImage('pacman-closed')) map.addImage('pacman-closed', imgClosed); };
if (!map.getSource('simulator-grid')) {
map.addSource('simulator-grid', { type: 'geojson', data: simulatorDataRef.current || emptyFc as any });
}
if (!map.getLayer('simulator-grid-fill')) {
map.addLayer({
id: 'simulator-grid-fill',
type: 'fill',
source: 'simulator-grid',
paint: {
'fill-color': [
'match', ['get', 'sim_status'],
'pending', currentIsDark ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
'skipped', currentIsDark ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
'processed', currentIsDark ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
'rgba(0,0,0,0)'
],
'fill-outline-color': currentIsDark ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'
}
});
}
if (!map.getSource('simulator-path')) {
map.addSource('simulator-path', { type: 'geojson', data: simulatorPathRef.current || emptyFc as any });
}
if (!map.getLayer('simulator-path-line')) {
map.addLayer({
id: 'simulator-path-line',
type: 'line',
source: 'simulator-path',
paint: {
'line-color': 'rgba(59, 130, 246, 0.4)',
'line-width': 1.5,
'line-dasharray': [3, 3]
}
});
}
if (!map.getSource('simulator-scanner')) {
map.addSource('simulator-scanner', { type: 'geojson', data: simulatorScannerRef.current || emptyFc as any });
}
if (!map.getLayer('simulator-scanner-pacman')) {
map.addLayer({
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 (map.getStyle()) setupMapLayers();
map.on('styledata', setupMapLayers);
map.on('style.load', setupMapLayers);
return () => {
map.off('styledata', setupMapLayers);
map.off('style.load', setupMapLayers);
if (map.getStyle()) {
if (map.getLayer('simulator-grid-fill')) map.removeLayer('simulator-grid-fill');
if (map.getLayer('simulator-path-line')) map.removeLayer('simulator-path-line');
if (map.getLayer('simulator-scanner-pacman')) map.removeLayer('simulator-scanner-pacman');
if (map.getSource('simulator-grid')) map.removeSource('simulator-grid');
if (map.getSource('simulator-path')) map.removeSource('simulator-path');
if (map.getSource('simulator-scanner')) map.removeSource('simulator-scanner');
}
};
}, [map]);
// Update Styles when theme changes
useEffect(() => {
if (!map || !map.getStyle()) return;
if (map.getLayer('simulator-grid-fill')) {
map.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)'
]);
map.setPaintProperty('simulator-grid-fill', 'fill-outline-color', isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)');
}
}, [map, isDarkStyle]);
// Update Data
useEffect(() => {
if (!map) return;
try {
if (map.getSource('simulator-grid')) (map.getSource('simulator-grid') as maplibregl.GeoJSONSource).setData(simulatorData || emptyFc as any);
if (map.getSource('simulator-path')) (map.getSource('simulator-path') as maplibregl.GeoJSONSource).setData(simulatorPath || emptyFc as any);
if (map.getSource('simulator-scanner')) (map.getSource('simulator-scanner') as maplibregl.GeoJSONSource).setData(simulatorScanner || emptyFc as any);
} catch (e) {
console.warn("Could not update simulator data", e);
}
}, [map, simulatorData, simulatorPath, simulatorScanner]);
return null;
}

View File

@ -4,6 +4,7 @@ import { PlusCircle } from 'lucide-react';
import { OngoingSearches } from './OngoingSearches';
import { GridSearchWizard } from './GridSearchWizard';
import { JobViewer } from './JobViewer';
import { useAppStore } from '@/store/appStore';
export default function GridSearch() {
const [sidebarWidth, setSidebarWidth] = useState(() => {
@ -11,6 +12,13 @@ export default function GridSearch() {
return saved ? parseInt(saved, 10) : 320;
});
const [isResizing, setIsResizing] = useState(false);
const { setShowGlobalFooter } = useAppStore();
useEffect(() => {
setShowGlobalFooter(false);
return () => setShowGlobalFooter(true);
}, [setShowGlobalFooter]);
const navigate = useNavigate();
const location = useLocation();

View File

@ -0,0 +1,82 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { useGridSimulatorState } from './hooks/useGridSimulatorState';
import { SimulatorControls } from './components/SimulatorControls';
import { SimulatorStats } from './components/SimulatorStats';
import { SimulatorSettingsPanel } from './components/SimulatorSettingsPanel';
import { GridSearchSimulatorProps } from './types';
import { T, translate } from '@/i18n';
export function GridSearchSimulator(props: GridSearchSimulatorProps) {
const state = useGridSimulatorState(props);
if (!props.pickerRegions || props.pickerRegions.length === 0) return null;
const handleCopyWaypoints = () => {
const hops = state.getFinalHopList();
navigator.clipboard.writeText(JSON.stringify(hops, null, 2))
.then(() => alert(translate("Waypoints copied to clipboard!")))
.catch(err => console.error(translate("Failed to copy"), err));
};
const handleExportWaypoints = () => {
const hops = state.getFinalHopList();
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(hops, null, 2));
const anchor = document.createElement('a');
anchor.href = dataStr;
anchor.download = "grid-search-waypoints.json";
anchor.click();
};
return (
<div className="relative overflow-hidden pt-2 flex flex-col">
<div className="space-y-4 pb-4 mb-4 border-b dark:border-gray-700">
<SimulatorControls
gridCells={state.gridCells}
progressIndex={state.progressIndex}
isPlaying={state.isPlaying}
speed={state.speed}
isCalculating={state.isCalculating}
pickerPolygons={props.pickerPolygons}
skippedCellsRef={state.skippedCellsRef}
setIsPlaying={state.setIsPlaying}
setProgressIndex={state.setProgressIndex}
setSpeed={state.setSpeed}
computeGrid={state.computeGrid}
handleClear={state.handleClear}
/>
<SimulatorStats
progressIndex={state.progressIndex}
totalCells={state.gridCells.length}
validCount={state.validCount}
skippedCount={state.skippedCount}
processedCount={state.processedCount}
handleCopyWaypoints={handleCopyWaypoints}
handleExportWaypoints={handleExportWaypoints}
/>
</div>
<SimulatorSettingsPanel {...state} />
{state.isCalculating && (
<div className="absolute top-0 left-0 right-0 bottom-0 bg-white/50 dark:bg-gray-900/50 flex flex-col items-center justify-center z-10 backdrop-blur-[1px]">
<div className="bg-white dark:bg-gray-800 shadow-lg border dark:border-gray-700 rounded-md p-4 flex flex-col items-center text-sm font-medium text-blue-600 dark:text-blue-400 max-w-xs text-center">
<div className="flex items-center mb-3">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span>
<T>Calculating...</T>
{state.calcStats.total > 0 && <span className="block text-xs font-normal text-gray-500 mt-1">{state.calcStats.current} / {state.calcStats.total} <T>cells</T></span>}
</span>
</div>
<button
onClick={() => { state.stopRequestedRef.current = true; }}
className="w-full bg-red-600 hover:bg-red-700 text-white py-1.5 px-3 rounded transition-colors text-xs font-semibold"
>
<T>Stop</T>
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import React, { useState, useEffect } from 'react';
export function DeferredNumberInput({ value, onChange, ...props }: any) {
const [local, setLocal] = useState(value);
useEffect(() => { setLocal(value); }, [value]);
const handleCommit = () => {
const num = Number(local);
if (!isNaN(num) && num !== value) onChange(num);
};
return (
<input
type="number"
{...props}
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={handleCommit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
)
}
export function DeferredRangeSlider({ value, onChange, ...props }: any) {
const [local, setLocal] = useState(value);
useEffect(() => { setLocal(value); }, [value]);
const handleCommit = () => {
const num = Number(local);
if (!isNaN(num) && num !== value) onChange(num);
};
return (
<input
type="range"
{...props}
value={local}
onChange={(e) => setLocal(Number(e.target.value))}
onMouseUp={handleCommit}
onTouchEnd={handleCommit}
onBlur={handleCommit}
/>
)
}

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Play, Pause, Square, FastForward, Eye, Bug } from 'lucide-react';
import { T, translate } from '@/i18n';
interface SimulatorControlsProps {
gridCells: any[];
progressIndex: number;
isPlaying: boolean;
speed: number;
isCalculating: boolean;
pickerPolygons: any[];
skippedCellsRef: React.MutableRefObject<any[]>;
setIsPlaying: (p: boolean) => void;
setProgressIndex: (i: number | ((prev: number) => number)) => void;
setSpeed: (s: number) => void;
computeGrid: (autoplay: boolean | 'preview') => void;
handleClear: () => void;
}
export function SimulatorControls({
gridCells, progressIndex, isPlaying, speed, isCalculating, pickerPolygons, skippedCellsRef,
setIsPlaying, setProgressIndex, setSpeed, computeGrid, handleClear
}: SimulatorControlsProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-1">
<button
onClick={() => {
if (gridCells.length === 0) {
computeGrid(true);
} else {
if (!isPlaying && progressIndex >= gridCells.length) {
setProgressIndex(0);
}
setIsPlaying(!isPlaying);
}
}}
disabled={isCalculating || !pickerPolygons || pickerPolygons.length === 0}
className={`p-2 rounded ${isPlaying ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<button
onClick={handleClear}
disabled={isCalculating}
className="p-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
title={translate("Clear Grid")}
>
<Square className="w-4 h-4" />
</button>
<button
onClick={() => {
setIsPlaying(false);
if (gridCells.length === 0) {
computeGrid('preview');
} else {
setProgressIndex(gridCells.length);
}
}}
disabled={isCalculating || !pickerPolygons || pickerPolygons.length === 0}
className="p-2 rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title={translate("Preview all cells")}
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1"><T>Preview</T></span>
</button>
<button
onClick={() => {
console.log('--- GRID SEARCH DEBUG DUMP ---');
console.log(`Valid Cells: ${gridCells.length}`, gridCells.slice(0, 5).map(c => c.properties));
const skipped = skippedCellsRef.current;
console.log(`Skipped Cells: ${skipped.length}`);
const skippedByReason = skipped.reduce((acc, cell) => {
const r = cell.properties?._reason || cell.properties?.sim_skip_reason || 'Unknown';
acc[r] = (acc[r] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Skipped Breakdown:', skippedByReason);
console.log('Skipped Sample (first 5):', skipped.slice(0, 5).map(c => c.properties));
console.log('------------------------------');
}}
disabled={isCalculating}
className="p-2 rounded bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title={translate("Print Debug Info to Console")}
>
<Bug className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1"><T>Debug</T></span>
</button>
</div>
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded p-1 text-xs">
<FastForward className="w-3 h-3 text-gray-500 mr-1" />
{[0.5, 1, 5, 10, 50].map(s => (
<button key={s} onClick={() => setSpeed(s)} className={`px-2 py-1 rounded ${speed === s ? 'bg-white dark:bg-gray-600 shadow-sm font-bold' : ''}`}>
{s}x
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,366 @@
import React, { useRef } from 'react';
import { Copy, Download, Upload } from 'lucide-react';
import { DeferredNumberInput, DeferredRangeSlider } from './DeferredInputs';
import { GridSimulatorSettings } from '../types';
import { T, translate } from '@/i18n';
interface SimulatorSettingsPanelProps {
// Current State
gridMode: string;
pathOrder: string;
groupByRegion: boolean;
cellSize: number;
cellOverlap: number;
centroidOverlap: number;
ghsFilterMode: string;
maxCellsLimit: number;
maxElevation: number;
minDensity: number;
minGhsPop: number;
minGhsBuilt: number;
enableElevation: boolean;
enableDensity: boolean;
enableGhsPop: boolean;
enableGhsBuilt: boolean;
allowMissingGhs: boolean;
bypassFilters: boolean;
ghsBounds: { minPop: number; maxPop: number; minBuilt: number; maxBuilt: number; };
// Setters
setGridMode: (v: any) => void;
setPathOrder: (v: any) => void;
setGroupByRegion: (v: any) => void;
setCellSize: (v: any) => void;
setCellOverlap: (v: any) => void;
setCentroidOverlap: (v: any) => void;
setGhsFilterMode: (v: any) => void;
setMaxCellsLimit: (v: any) => void;
setMaxElevation: (v: any) => void;
setMinDensity: (v: any) => void;
setMinGhsPop: (v: any) => void;
setMinGhsBuilt: (v: any) => void;
setEnableElevation: (v: any) => void;
setEnableDensity: (v: any) => void;
setEnableGhsPop: (v: any) => void;
setEnableGhsBuilt: (v: any) => void;
setAllowMissingGhs: (v: any) => void;
setBypassFilters: (v: any) => void;
// Actions
getCurrentSettings: () => GridSimulatorSettings;
}
export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCopySettings = () => {
navigator.clipboard.writeText(JSON.stringify(props.getCurrentSettings(), null, 2))
.then(() => alert(translate("Settings copied to clipboard!")))
.catch(err => console.error(translate("Failed to copy"), err));
};
const handleExportSettings = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(props.getCurrentSettings(), null, 2));
const anchor = document.createElement('a');
anchor.href = dataStr;
anchor.download = "grid-simulator-settings.json";
anchor.click();
};
const handleImportSettings = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const json = JSON.parse(evt.target?.result as string);
if (json.gridMode) props.setGridMode(json.gridMode);
if (json.pathOrder) props.setPathOrder(json.pathOrder);
if (json.groupByRegion !== undefined) props.setGroupByRegion(json.groupByRegion);
if (json.cellSize !== undefined) props.setCellSize(json.cellSize);
if (json.cellOverlap !== undefined) props.setCellOverlap(json.cellOverlap);
if (json.centroidOverlap !== undefined) props.setCentroidOverlap(json.centroidOverlap);
if (json.ghsFilterMode) props.setGhsFilterMode(json.ghsFilterMode);
if (json.maxCellsLimit !== undefined) props.setMaxCellsLimit(json.maxCellsLimit);
if (json.maxElevation !== undefined) props.setMaxElevation(json.maxElevation);
if (json.minDensity !== undefined) props.setMinDensity(json.minDensity);
if (json.minGhsPop !== undefined) props.setMinGhsPop(json.minGhsPop);
if (json.minGhsBuilt !== undefined) props.setMinGhsBuilt(json.minGhsBuilt);
if (json.enableElevation !== undefined) props.setEnableElevation(json.enableElevation);
if (json.enableDensity !== undefined) props.setEnableDensity(json.enableDensity);
if (json.enableGhsPop !== undefined) props.setEnableGhsPop(json.enableGhsPop);
if (json.enableGhsBuilt !== undefined) props.setEnableGhsBuilt(json.enableGhsBuilt);
if (json.allowMissingGhs !== undefined) props.setAllowMissingGhs(json.allowMissingGhs);
if (json.bypassFilters !== undefined) props.setBypassFilters(json.bypassFilters);
} catch (err) {
alert(translate("Invalid JSON file"));
console.error(err);
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="space-y-3">
<div className="flex justify-between items-center bg-gray-50 dark:bg-gray-800/50 p-2 rounded border border-gray-100 dark:border-gray-700/50">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
<T>Settings</T>
</h4>
<div className="flex gap-2">
<button onClick={handleCopySettings} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title={translate("Copy settings to clipboard")}>
<Copy className="w-4 h-4" />
</button>
<button onClick={handleExportSettings} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title={translate("Download settings JSON")}>
<Download className="w-4 h-4" />
</button>
<button onClick={() => fileInputRef.current?.click()} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title={translate("Import settings JSON")}>
<Upload className="w-4 h-4" />
</button>
<input type="file" ref={fileInputRef} className="hidden" accept="application/json" onChange={handleImportSettings} />
</div>
</div>
<div className="flex flex-col gap-3 mb-3 mt-2">
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Grid Simulation Mode</T></label>
<div className="flex gap-2">
<button
onClick={() => props.setGridMode('hex')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.gridMode === 'hex' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Hex Geometric Grid</T>
</button>
<button
onClick={() => props.setGridMode('square')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.gridMode === 'square' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Square Geometric Grid</T>
</button>
<button
onClick={() => props.setGridMode('admin')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.gridMode === 'admin' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Native GADM Regions</T>
</button>
<button
onClick={() => props.setGridMode('centers')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.gridMode === 'centers' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>GHS Centers</T>
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Scan Trajectory</T></label>
<div className="flex gap-2 mb-2">
<button
onClick={() => props.setPathOrder('zigzag')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.pathOrder === 'zigzag' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Zig-Zag</T>
</button>
<button
onClick={() => props.setPathOrder('snake')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.pathOrder === 'snake' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Snake</T>
</button>
<button
onClick={() => props.setPathOrder('spiral-out')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.pathOrder === 'spiral-out' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral Out</T>
</button>
<button
onClick={() => props.setPathOrder('spiral-in')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.pathOrder === 'spiral-in' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral In</T>
</button>
<button
onClick={() => props.setPathOrder('shortest')}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.pathOrder === 'shortest' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Shortest</T>
</button>
</div>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mt-2 cursor-pointer">
<input
type="checkbox"
checked={props.groupByRegion}
onChange={e => props.setGroupByRegion(e.target.checked)}
className="mr-2"
/>
<T>Constrain process sequentially per boundary</T>
</label>
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t dark:border-gray-700">
<div className={props.enableElevation ? '' : 'opacity-40'}>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<input type="checkbox" checked={props.enableElevation} onChange={(e) => props.setEnableElevation(e.target.checked)} className="mr-2" />
<T>Max Elevation (m)</T>
</label>
<div className={props.enableElevation ? '' : 'pointer-events-none select-none'}>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.maxElevation}
onChange={(num: number) => props.setMaxElevation(num)}
/>
<span className="text-[10px] text-gray-500"><T>Drop &gt;</T> {props.maxElevation}m</span>
</div>
</div>
<div className={props.enableDensity ? '' : 'opacity-40'}>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<input type="checkbox" checked={props.enableDensity} onChange={(e) => props.setEnableDensity(e.target.checked)} className="mr-2" />
<T>Min Density (p/km²)</T>
</label>
<div className={props.enableDensity ? '' : 'pointer-events-none select-none'}>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.minDensity}
onChange={(num: number) => props.setMinDensity(num)}
/>
<span className="text-[10px] text-gray-500"><T>Drop &lt;</T> {props.minDensity}</span>
</div>
</div>
<div className="col-span-2 flex flex-col border-b pb-2 mb-2 mt-2 dark:border-gray-700 gap-2">
<div>
<h3 className="text-xs font-bold text-gray-800 dark:text-gray-200"><T>GHS Data Thresholds</T></h3>
<p className="text-[10px] text-gray-500"><T>Logic when filters are active.</T></p>
</div>
<div className="flex flex-col gap-3 items-start mt-2">
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title={translate("If enabled, it ignores ALL density, elevation, geometry overlap, and custom filters. Will push all raw items")}>
<input type="checkbox" checked={props.bypassFilters} onChange={e => props.setBypassFilters(e.target.checked)} className="mr-1" />
<T>Bypass All Filters</T>
</label>
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title={translate("If a region is physically missing GHS raster data, allow it to pass instead of auto-failing")}>
<input type="checkbox" checked={props.allowMissingGhs} onChange={e => props.setAllowMissingGhs(e.target.checked)} className="mr-1" />
<T>Allow Missing Data Gaps</T>
</label>
<div className="flex items-center gap-2 cursor-pointer" onClick={() => props.setGhsFilterMode(props.ghsFilterMode === 'AND' ? 'OR' : 'AND')}>
<span className={`text-[10px] font-semibold ${props.ghsFilterMode === 'AND' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>AND</span>
<button
type="button"
className="relative inline-flex h-4 w-8 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none"
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${props.ghsFilterMode === 'OR' ? 'translate-x-4' : 'translate-x-1'}`} />
</button>
<span className={`text-[10px] font-semibold ${props.ghsFilterMode === 'OR' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>OR</span>
</div>
</div>
</div>
<div className={`col-span-2 ${props.enableGhsPop ? '' : 'opacity-50'}`}>
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<span className="flex items-center">
<input type="checkbox" checked={props.enableGhsPop} onChange={(e) => props.setEnableGhsPop(e.target.checked)} className="mr-2" />
<T>Min GHS Population</T>
</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.minGhsPop.toLocaleString()}</span>
</label>
<div className={`flex gap-2 items-center ${props.enableGhsPop ? '' : 'pointer-events-none select-none'}`}>
<span className="text-[10px] text-gray-400">{props.ghsBounds.minPop > 0 ? props.ghsBounds.minPop.toLocaleString() : '0'}</span>
<DeferredRangeSlider
min={Math.floor(props.ghsBounds.minPop)}
max={Math.ceil(props.ghsBounds.maxPop)}
step={parseInt(((props.ghsBounds.maxPop - props.ghsBounds.minPop) / 100).toFixed(0)) || 1}
value={props.minGhsPop}
onChange={(num: number) => props.setMinGhsPop(num)}
className="flex-1"
/>
<span className="text-[10px] text-gray-400">{props.ghsBounds.maxPop > 0 ? props.ghsBounds.maxPop.toLocaleString() : translate('Max')}</span>
</div>
</div>
<div className={`col-span-2 ${props.enableGhsBuilt ? '' : 'opacity-50'}`}>
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
<span className="flex items-center">
<input type="checkbox" checked={props.enableGhsBuilt} onChange={(e) => props.setEnableGhsBuilt(e.target.checked)} className="mr-2" />
<T>Min GHS Built Area</T>
</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.minGhsBuilt.toLocaleString()}</span>
</label>
<div className={`flex gap-2 items-center ${props.enableGhsBuilt ? '' : 'pointer-events-none select-none'}`}>
<span className="text-[10px] text-gray-400">{props.ghsBounds.minBuilt > 0 ? props.ghsBounds.minBuilt.toLocaleString() : '0'}</span>
<DeferredRangeSlider
min={Math.floor(props.ghsBounds.minBuilt)}
max={Math.ceil(props.ghsBounds.maxBuilt)}
step={parseInt(((props.ghsBounds.maxBuilt - props.ghsBounds.minBuilt) / 100).toFixed(0)) || 1}
value={props.minGhsBuilt}
onChange={(num: number) => props.setMinGhsBuilt(num)}
className="flex-1"
/>
<span className="text-[10px] text-gray-400">{props.ghsBounds.maxBuilt > 0 ? props.ghsBounds.maxBuilt.toLocaleString() : translate('Max')}</span>
</div>
</div>
</div>
</div>
<div className={`bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md space-y-3 transition-opacity ${props.gridMode === 'admin' ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Cell Base Size (km)</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.cellSize}
onChange={(num: number) => props.setCellSize(num)}
min={0.5}
step={0.5}
disabled={props.gridMode === 'admin'}
/>
</div>
{props.gridMode === 'centers' ? (
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Centroid Overlap Allowed %</T></label>
<DeferredRangeSlider
className="w-full"
value={props.centroidOverlap}
onChange={(num: number) => props.setCentroidOverlap(num)}
min={0}
max={100}
step={1}
title={translate("0% means centroids must be at least 1 cell-size apart. 100% allows exact duplicates.")}
/>
<div className="flex justify-between text-[10px] text-gray-500">
<span><T>No Overlap (Dist)</T></span>
<span><T>Full Overlap</T></span>
</div>
</div>
) : (
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Overlap %</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.cellOverlap}
onChange={(num: number) => props.setCellOverlap(num)}
min={0}
max={100}
step={1}
title={translate("Increase to balloon the geometric cells and ensure no places are missed at the seams.")}
disabled={props.gridMode === 'admin'}
/>
</div>
)}
</div>
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Grid Generation Limit</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.maxCellsLimit}
onChange={(num: number) => props.setMaxCellsLimit(num)}
step={500}
disabled={props.gridMode === 'admin'}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Copy, Download } from 'lucide-react';
import { T, translate } from '@/i18n';
interface SimulatorStatsProps {
progressIndex: number;
validCount: number;
skippedCount: number;
processedCount: number;
totalCells: number;
handleCopyWaypoints: () => void;
handleExportWaypoints: () => void;
}
export function SimulatorStats({
progressIndex, validCount, skippedCount, processedCount, totalCells, handleCopyWaypoints, handleExportWaypoints
}: SimulatorStatsProps) {
return (
<div>
<div className="flex justify-between text-xs mb-1 text-gray-600 dark:text-gray-400">
<span><T>Scanning...</T></span>
<span className="font-mono">{progressIndex} / {totalCells}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div className="bg-blue-500 h-2 transition-all duration-75" style={{ width: totalCells > 0 ? `${(progressIndex / totalCells) * 100}%` : '0%' }}></div>
</div>
<div className="grid grid-cols-3 gap-2 mt-3 text-center text-xs h-full">
<div className="bg-gray-50 dark:bg-gray-700 p-2 rounded flex flex-col justify-between h-full">
<div>
<span className="block text-gray-500 font-semibold mb-1"><T>Target Calls</T></span>
<span className="font-mono text-gray-800 dark:text-gray-200">{validCount}</span>
</div>
{totalCells > 0 && (
<div className="flex gap-3 mt-2 pt-2 border-t border-gray-200 dark:border-gray-600 justify-center">
<button onClick={handleCopyWaypoints} className="text-gray-400 hover:text-sky-600 focus:outline-none flex items-center gap-1" title={translate("Copy Waypoints (GeoJSON)")}>
<Copy className="w-3.5 h-3.5" />
</button>
<button onClick={handleExportWaypoints} className="text-gray-400 hover:text-sky-600 focus:outline-none flex items-center gap-1" title={translate("Download Waypoints (GeoJSON)")}>
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-2 rounded flex flex-col justify-center h-full">
<span className="block text-red-500 font-semibold mb-1"><T>Skipped Calls</T></span>
<span className="font-mono text-red-800 dark:text-red-300">{skippedCount}</span>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-2 rounded flex flex-col justify-center h-full">
<span className="block text-green-600 font-semibold mb-1"><T>Simulated Processed</T></span>
<span className="font-mono text-green-800 dark:text-green-300">{processedCount}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,396 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import * as turf from '@turf/turf';
import { generateGridSearchCells } from '@polymech/shared';
import { GridSimulatorSettings, GridSearchSimulatorProps } from '../types';
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 function useGridSimulatorState({
pickerRegions,
pickerPolygons,
onFilterCell,
setSimulatorData,
setSimulatorPath,
setSimulatorScanner
}: GridSearchSimulatorProps) {
const [gridCells, setGridCells] = useState<any[]>([]);
const [progressIndex, setProgressIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(0.5);
const [skippedTotal, setSkippedTotal] = useState(0);
// Config
const [gridMode, setGridMode] = useLocalStorage<'hex' | 'square' | 'admin' | 'centers'>('pm_gridMode', 'hex');
const [pathOrder, setPathOrder] = useLocalStorage<'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'>('pm_pathOrder', 'snake');
const [groupByRegion, setGroupByRegion] = useLocalStorage<boolean>('pm_groupByRegion', true);
const [cellSize, setCellSize] = useLocalStorage<number>('pm_cellSize', 2.5);
const [cellOverlap, setCellOverlap] = useLocalStorage<number>('pm_cellOverlap', 0);
const [centroidOverlap, setCentroidOverlap] = useLocalStorage<number>('pm_centroidOverlap', 50);
const [ghsFilterMode, setGhsFilterMode] = useLocalStorage<'AND' | 'OR'>('pm_ghsFilterMode', 'AND');
const [maxCellsLimit, setMaxCellsLimit] = useLocalStorage<number>('pm_maxCellsLimit', 15000);
const [maxElevation, setMaxElevation] = useLocalStorage<number>('pm_maxElevation', 700);
const [minDensity, setMinDensity] = useLocalStorage<number>('pm_minDensity', 10);
const [minGhsPop, setMinGhsPop] = useLocalStorage<number>('pm_minGhsPop', 0);
const [minGhsBuilt, setMinGhsBuilt] = useLocalStorage<number>('pm_minGhsBuilt', 0);
const [enableElevation, setEnableElevation] = useLocalStorage<boolean>('pm_enElev', false);
const [enableDensity, setEnableDensity] = useLocalStorage<boolean>('pm_enDens', false);
const [enableGhsPop, setEnableGhsPop] = useLocalStorage<boolean>('pm_enPop', false);
const [enableGhsBuilt, setEnableGhsBuilt] = useLocalStorage<boolean>('pm_enBuilt', false);
const [allowMissingGhs, setAllowMissingGhs] = useLocalStorage<boolean>('pm_allowMissGhs', false);
const [bypassFilters, setBypassFilters] = useLocalStorage<boolean>('pm_bypassFilters', false);
const [isCalculating, setIsCalculating] = useState(false);
const [calcStats, setCalcStats] = useState({ current: 0, total: 0, valid: 0 });
const skippedCellsRef = useRef<any[]>([]);
const stopRequestedRef = useRef<boolean>(false);
const reqRef = useRef<number>();
const lastTickRef = useRef<number>(0);
const globalProcessedHopsRef = useRef<any[]>([]);
const [ghsBounds, setGhsBounds] = useState({ minPop: 0, maxPop: 1000000, minBuilt: 0, maxBuilt: 1000000 });
useEffect(() => {
if (!pickerPolygons || pickerPolygons.length === 0) return;
let minPop = Infinity;
let maxPop = -Infinity;
let minBuilt = Infinity;
let maxBuilt = -Infinity;
pickerPolygons.forEach(fc => {
if (fc && fc.features) {
fc.features.forEach((f: any) => {
const raw = f.properties || {};
const pop = raw.ghsPopulation;
const built = raw.ghsBuiltWeight;
if (typeof pop === 'number') {
if (pop < minPop) minPop = pop;
if (pop > maxPop) maxPop = pop;
}
if (typeof built === 'number') {
if (built < minBuilt) minBuilt = built;
if (built > maxBuilt) maxBuilt = built;
}
});
}
});
if (minPop !== Infinity) {
setGhsBounds({
minPop, maxPop, minBuilt: minBuilt === Infinity ? 0 : minBuilt, maxBuilt: maxBuilt === -Infinity ? 1000000 : maxBuilt
});
}
}, [pickerPolygons]);
const getFinalHopList = () => {
return gridCells
.filter(c => c.properties.sim_status !== 'skipped')
.map((c, i) => {
const pt = turf.centroid(c).geometry.coordinates;
return {
step: i + 1,
lng: Number(pt[0].toFixed(6)),
lat: Number(pt[1].toFixed(6)),
radius_km: c.properties.search_radius_km ? Number(c.properties.search_radius_km.toFixed(2)) : undefined
};
});
};
const getCurrentSettings = (): GridSimulatorSettings => ({
gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap,
ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt,
enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters
});
const computeGrid = useCallback(async (autoPlay: boolean | 'preview' = false) => {
if (!pickerRegions || pickerRegions.length === 0 || !pickerPolygons) {
setGridCells([]);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
return;
}
const features: any[] = [];
pickerPolygons.forEach(fc => {
if (fc && fc.features) {
features.push(...fc.features);
}
});
if (features.length === 0) return;
setIsCalculating(true);
stopRequestedRef.current = false;
setCalcStats({ current: 0, total: 0, valid: 0 });
await new Promise(r => setTimeout(r, 10));
try {
const result = await generateGridSearchCells({
features, gridMode, cellSize, pathOrder, groupByRegion, onFilterCell,
maxElevation: enableElevation ? maxElevation : 0,
minDensity: enableDensity ? minDensity : 0,
minGhsPop: enableGhsPop ? minGhsPop : 0,
minGhsBuilt: enableGhsBuilt ? minGhsBuilt : 0,
allowMissingGhs,
bypassFilters,
cellOverlap: cellOverlap / 100,
centroidOverlap: centroidOverlap / 100,
ghsFilterMode,
maxCellsLimit,
skipPolygons: globalProcessedHopsRef.current
}, async (stats) => {
setCalcStats({ current: stats.current, total: stats.total, valid: stats.validCells.length });
if (stopRequestedRef.current) return false;
await new Promise(r => setTimeout(r, 0));
return true;
});
if (result.error) {
alert(result.error);
setIsCalculating(false);
return;
}
setGridCells(result.validCells);
skippedCellsRef.current = result.skippedCells || [];
setSkippedTotal(result.skippedCells ? result.skippedCells.length : 0);
if (autoPlay === 'preview') {
setProgressIndex(result.validCells.length);
setIsPlaying(false);
} else {
setProgressIndex(0);
setIsPlaying(stopRequestedRef.current ? false : autoPlay);
}
setSimulatorData(turf.featureCollection(result.validCells));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
setIsCalculating(false);
} catch (err) {
console.error("Turf Error:", err);
alert("An error occurred during grid generation.");
setIsCalculating(false);
}
}, [pickerRegions, pickerPolygons, gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, onFilterCell, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters, setSimulatorData, setSimulatorPath, setSimulatorScanner]);
const activeSettingsRef = useRef({ gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters });
useEffect(() => {
const prev = activeSettingsRef.current;
let changed = false;
if (prev.gridMode !== gridMode) changed = true;
if (prev.cellSize !== cellSize) changed = true;
if (prev.cellOverlap !== cellOverlap) changed = true;
if (prev.centroidOverlap !== centroidOverlap) changed = true;
if (prev.maxElevation !== maxElevation) changed = true;
if (prev.minDensity !== minDensity) changed = true;
if (prev.minGhsPop !== minGhsPop) changed = true;
if (prev.minGhsBuilt !== minGhsBuilt) changed = true;
if (prev.ghsFilterMode !== ghsFilterMode) changed = true;
if (prev.pathOrder !== pathOrder) changed = true;
if (prev.groupByRegion !== groupByRegion) changed = true;
if (prev.maxCellsLimit !== maxCellsLimit) changed = true;
if (prev.enableElevation !== enableElevation) changed = true;
if (prev.enableDensity !== enableDensity) changed = true;
if (prev.enableGhsPop !== enableGhsPop) changed = true;
if (prev.enableGhsBuilt !== enableGhsBuilt) changed = true;
if (prev.allowMissingGhs !== allowMissingGhs) changed = true;
if (prev.bypassFilters !== bypassFilters) changed = true;
if (changed) {
globalProcessedHopsRef.current = [];
activeSettingsRef.current = { gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters };
if (pickerPolygons && pickerPolygons.length > 0) {
setTimeout(() => computeGrid('preview'), 0);
}
}
}, [gridMode, cellSize, cellOverlap, centroidOverlap, maxElevation, minDensity, minGhsPop, minGhsBuilt, ghsFilterMode, pathOrder, groupByRegion, maxCellsLimit, pickerPolygons, computeGrid, enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters]);
const prevRegionsRef = useRef<string>('');
useEffect(() => {
const currentRegions = (pickerRegions || []).map(r => r.gid).sort().join(',');
if (prevRegionsRef.current !== currentRegions) {
prevRegionsRef.current = currentRegions;
setGridCells([]);
setIsPlaying(false);
setProgressIndex(0);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
}
}, [pickerRegions, setSimulatorData, setSimulatorPath, setSimulatorScanner]);
const handleClear = useCallback(() => {
setGridCells([]);
setIsPlaying(false);
setProgressIndex(0);
setSimulatorData(turf.featureCollection([]));
setSimulatorPath(turf.featureCollection([]));
setSimulatorScanner(turf.featureCollection([]));
globalProcessedHopsRef.current = [];
}, [setSimulatorData, setSimulatorPath, setSimulatorScanner]);
// Animation Loop
useEffect(() => {
if (!isPlaying || progressIndex >= gridCells.length) {
if (progressIndex >= gridCells.length && isPlaying) setIsPlaying(false);
return;
}
const tick = (time: number) => {
if (time - lastTickRef.current > (1000 / (10 * speed))) {
setProgressIndex(prev => {
if (prev < gridCells.length) {
const cell = gridCells[prev];
if (cell.properties.sim_status !== 'skipped') {
globalProcessedHopsRef.current.push(cell);
}
}
const next = prev + 1;
return next > gridCells.length ? gridCells.length : next;
});
lastTickRef.current = time;
}
reqRef.current = requestAnimationFrame(tick);
};
reqRef.current = requestAnimationFrame(tick);
return () => {
if (reqRef.current) cancelAnimationFrame(reqRef.current);
};
}, [isPlaying, progressIndex, gridCells.length, speed]);
// Sync State to GeoJSON
useEffect(() => {
if (gridCells.length === 0) return;
const updatedCells = gridCells.map((cell, i) => {
if (cell.properties.sim_status === 'skipped') return cell;
return {
...cell,
properties: {
...cell.properties,
sim_status: i < progressIndex ? 'processed' : 'pending'
}
};
});
setSimulatorData(turf.featureCollection(updatedCells));
const pathCoords = gridCells
.slice(0, progressIndex)
.filter(c => c.properties.sim_status !== 'skipped')
.map(c => turf.centroid(c).geometry.coordinates);
if (pathCoords.length > 1) {
setSimulatorPath(turf.featureCollection([turf.lineString(pathCoords)]));
} else {
setSimulatorPath(turf.featureCollection([]));
}
const activeCells = gridCells.filter(c => c.properties.sim_status !== 'skipped');
const currentIndex = gridCells.slice(0, progressIndex).filter(c => c.properties.sim_status !== 'skipped').length;
if (currentIndex > 0 && currentIndex <= activeCells.length) {
const currentCell = activeCells[currentIndex - 1];
const centroid = turf.centroid(currentCell);
let angle = 0;
if (currentIndex > 1) {
const prevCell = activeCells[currentIndex - 2];
angle = turf.bearing(turf.centroid(prevCell), centroid);
} else if (currentIndex === 1 && activeCells.length > 1) {
const nextCell = activeCells[1];
angle = turf.bearing(centroid, turf.centroid(nextCell));
}
centroid.properties = {
bearing: angle,
icon_state: (progressIndex % 2 === 0) ? 'pacman-open' : 'pacman-closed'
};
setSimulatorScanner(turf.featureCollection([centroid]));
} else {
setSimulatorScanner(turf.featureCollection([]));
}
}, [progressIndex, gridCells, setSimulatorData, setSimulatorPath, setSimulatorScanner]);
const processedCount = progressIndex;
const skippedCount = skippedTotal;
const validCount = gridCells.length;
return {
// State
gridCells,
progressIndex,
isPlaying,
speed,
isCalculating,
calcStats,
skippedCellsRef,
stopRequestedRef,
// Metrics
processedCount,
skippedCount,
validCount,
ghsBounds,
// Configuration actions
setGridMode,
setPathOrder,
setGroupByRegion,
setCellSize,
setCellOverlap,
setCentroidOverlap,
setGhsFilterMode,
setMaxCellsLimit,
setMaxElevation,
setMinDensity,
setMinGhsPop,
setMinGhsBuilt,
setEnableElevation,
setEnableDensity,
setEnableGhsPop,
setEnableGhsBuilt,
setAllowMissingGhs,
setBypassFilters,
// Core Actions
setIsPlaying,
setProgressIndex,
setSpeed,
computeGrid,
handleClear,
getFinalHopList,
getCurrentSettings,
// Config variables
gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap,
ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt,
enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters
};
}

View File

@ -0,0 +1,19 @@
import { GridGeneratorOptions } from '@polymech/shared';
type SimulatorBaseConfig = Required<Omit<GridGeneratorOptions, 'features' | 'onFilterCell' | 'skipPolygons'>>;
export interface GridSimulatorSettings extends SimulatorBaseConfig {
enableElevation: boolean;
enableDensity: boolean;
enableGhsPop: boolean;
enableGhsBuilt: boolean;
}
export interface GridSearchSimulatorProps {
pickerRegions: any[];
pickerPolygons: any[];
onFilterCell?: (cell: any) => boolean;
setSimulatorData: (data: any) => void;
setSimulatorPath: (data: any) => void;
setSimulatorScanner: (data: any) => void;
}

View File

@ -45,21 +45,49 @@ export function useGridSearchState() {
updateParams({ search: val, polygons: null });
}, [updateParams]);
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 getPersistentToggle = useCallback((key: string, urlValue: string | null, defaultValue: boolean): boolean => {
if (typeof window === 'undefined') return defaultValue;
if (urlValue !== null) {
localStorage.setItem(`gridsearch_${key}`, urlValue);
return urlValue === '1';
}
const stored = localStorage.getItem(`gridsearch_${key}`);
if (stored !== null) return stored === '1';
return defaultValue;
}, []);
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 getPersistentString = useCallback((key: string, urlValue: string | null): string | null => {
if (typeof window === 'undefined') return null;
if (urlValue !== null) {
localStorage.setItem(`gridsearch_${key}`, urlValue);
return urlValue;
}
return localStorage.getItem(`gridsearch_${key}`);
}, []);
const includeStats = getPersistentToggle('stats', searchParams.get('stats'), true);
const showDensity = getPersistentToggle('density', searchParams.get('density'), false);
const showCenters = getPersistentToggle('centers', searchParams.get('centers'), false);
const gadmPickerActive = getPersistentToggle('picker', searchParams.get('picker'), false);
const urlStyle = getPersistentString('style', searchParams.get('style'));
const posterMode = getPersistentToggle('poster', searchParams.get('poster'), false);
const posterTheme = getPersistentString('theme', searchParams.get('theme')) || 'terracotta';
const setPersistentParam = useCallback((key: string, val: string | null) => {
if (typeof window !== 'undefined') {
if (val === null) localStorage.removeItem(`gridsearch_${key}`);
else localStorage.setItem(`gridsearch_${key}`, val);
}
updateParam(key, val);
}, [updateParam]);
const setIncludeStats = useCallback((val: boolean) => setPersistentParam('stats', val ? '1' : '0'), [setPersistentParam]);
const setShowDensity = useCallback((val: boolean) => setPersistentParam('density', val ? '1' : '0'), [setPersistentParam]);
const setShowCenters = useCallback((val: boolean) => setPersistentParam('centers', val ? '1' : '0'), [setPersistentParam]);
const setGadmPickerActive = useCallback((val: boolean) => setPersistentParam('picker', val ? '1' : '0'), [setPersistentParam]);
const setUrlStyle = useCallback((style: string) => setPersistentParam('style', style), [setPersistentParam]);
const setPosterMode = useCallback((val: boolean) => setPersistentParam('poster', val ? '1' : '0'), [setPersistentParam]);
const setPosterTheme = useCallback((theme: string) => setPersistentParam('theme', theme), [setPersistentParam]);
const [pickerRegions, setPickerRegions] = useState<any[]>([]);
const [pickerPolygons, setPickerPolygons] = useState<any[]>([]);