Maintenance Love :)
This commit is contained in:
parent
c052eebca9
commit
77b2f41ee0
2382
packages/ui/shared/package-lock.json
generated
2382
packages/ui/shared/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.1.5",
|
||||
"@turf/turf": "^7.3.4",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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 > {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 < {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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 ></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 <</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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
19
packages/ui/src/modules/places/gridsearch/simulator/types.ts
Normal file
19
packages/ui/src/modules/places/gridsearch/simulator/types.ts
Normal 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;
|
||||
}
|
||||
@ -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[]>([]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user