Maintenance Love :)

This commit is contained in:
lovebird 2026-03-24 11:00:01 +01:00
parent 021dc7b0c3
commit 1b5bf73320
6 changed files with 310 additions and 146 deletions

View File

@ -16,6 +16,8 @@ export interface GridFeatureProperties {
ghsBuiltWeight?: number;
ghsPopCenter?: [number, number];
ghsBuiltCenter?: [number, number];
ghsPopCenters?: number[][];
ghsBuiltCenters?: number[][];
[key: string]: any;
}
@ -333,21 +335,39 @@ async function generateCenterCells(
if (!shouldContinue) break;
}
const centers: [number, number][] = [];
if (props.ghsPopCenter && Array.isArray(props.ghsPopCenter)) centers.push(props.ghsPopCenter as [number, number]);
if (props.ghsBuiltCenter && Array.isArray(props.ghsBuiltCenter)) centers.push(props.ghsBuiltCenter as [number, number]);
const centersMap = new Map<string, { coord: [number, number], popWeight?: number, builtWeight?: number }>();
const addCenter = (coord: [number, number], type: 'pop' | 'built', weight?: number) => {
const key = `${coord[0].toFixed(5)},${coord[1].toFixed(5)}`;
if (!centersMap.has(key)) centersMap.set(key, { coord });
const entry = centersMap.get(key)!;
if (type === 'pop' && weight !== undefined) entry.popWeight = weight;
if (type === 'built' && weight !== undefined) entry.builtWeight = weight;
};
const uniqueCenters: [number, number][] = [];
for (const c of centers) {
if (!uniqueCenters.some(uc => uc[0] === c[0] && uc[1] === c[1])) uniqueCenters.push(c);
if (props.ghsPopCenter && Array.isArray(props.ghsPopCenter)) addCenter(props.ghsPopCenter as [number, number], 'pop', props.ghsPopulation);
if (props.ghsBuiltCenter && Array.isArray(props.ghsBuiltCenter)) addCenter(props.ghsBuiltCenter as [number, number], 'built', props.ghsBuiltWeight);
if (Array.isArray(props.ghsPopCenters)) {
props.ghsPopCenters.forEach((c: number[]) => addCenter([c[0], c[1]], 'pop', c[2]));
}
if (Array.isArray(props.ghsBuiltCenters)) {
props.ghsBuiltCenters.forEach((c: number[]) => addCenter([c[0], c[1]], 'built', c[2]));
}
const uniqueCenters = Array.from(centersMap.values());
for (let j = 0; j < uniqueCenters.length; j++) {
const center = uniqueCenters[j];
const pt = turf.point(center);
const { coord, popWeight, builtWeight } = uniqueCenters[j];
const pt = turf.point(coord);
const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000);
let { allowed, reason } = checkCellFilters(props, options, areaSqKm);
const centerProps = {
...props,
ghsPopulation: popWeight !== undefined ? popWeight : props.ghsPopulation,
ghsBuiltWeight: builtWeight !== undefined ? builtWeight : props.ghsBuiltWeight
};
let { allowed, reason } = checkCellFilters(centerProps, options, areaSqKm);
if (allowed && acceptedCenters.length > 0) {
const minAllowedDistance = cellSize * (1 - centroidOverlap);

View File

@ -155,6 +155,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
const [sidebarWidth, setSidebarWidth] = useState(400);
const [isResizing, setIsResizing] = useState(false);
const [leftSidebarWidth, setLeftSidebarWidth] = useState(384);
const [isLeftResizing, setIsLeftResizing] = useState(false);
// Info Panel State
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
@ -318,7 +320,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
});
}
}
}, [selectedLocation, sidebarWidth, gadmPickerActive, simulatorActive]);
}, [selectedLocation, sidebarWidth, leftSidebarWidth, gadmPickerActive, simulatorActive]);
// Resizing Handlers
const startResizing = useCallback(() => setIsResizing(true), []);
@ -332,14 +334,26 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
}
}, [isResizing]);
const startLeftResizing = useCallback(() => setIsLeftResizing(true), []);
const stopLeftResizing = useCallback(() => setIsLeftResizing(false), []);
const resizeLeft = useCallback((e: MouseEvent) => {
if (isLeftResizing) {
setLeftSidebarWidth(Math.max(100, e.clientX));
}
}, [isLeftResizing]);
useEffect(() => {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
window.addEventListener('mousemove', resizeLeft);
window.addEventListener('mouseup', stopLeftResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
window.removeEventListener('mousemove', resizeLeft);
window.removeEventListener('mouseup', stopLeftResizing);
};
}, [resize, stopResizing]);
}, [resize, stopResizing, resizeLeft, stopLeftResizing]);
// Group locations by city
@ -365,11 +379,15 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
`}</style>
{/* Split View Container */}
<div ref={mapWrapper} className="flex-1 w-full h-full min-h-[600px] flex flex-col xl:flex-row [&:fullscreen]:h-screen overflow-hidden relative bg-white dark:bg-gray-900">
<div ref={mapWrapper} className="flex-1 w-full h-full min-h-[600px] flex flex-col md:flex-row [&:fullscreen]:h-screen overflow-hidden relative bg-white dark:bg-gray-900">
{/* Left Tools Sidebar */}
{(gadmPickerActive || simulatorActive) && (
<div className="w-96 flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 z-10 overflow-hidden shadow-xl transition-all duration-300">
<>
<div
className="flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 z-10 overflow-hidden shadow-xl"
style={{ width: leftSidebarWidth }}
>
<Tabs
value={gadmPickerActive ? 'gadm' : 'simulator'}
onValueChange={(val) => {
@ -424,6 +442,14 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
</TabsContent>
</Tabs>
</div>
{/* Drag Handle for Left Sidebar */}
<div
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
onMouseDown={startLeftResizing}
>
<GripVertical className="w-3 h-3 text-gray-400" />
</div>
</>
)}
{/* Map Panel & Center Layout */}

View File

@ -254,3 +254,29 @@ export const fetchRegionBoundary = async (
return await res.json();
});
};
export interface GadmRow {
level: number;
queryName: string;
gadmName: string | null;
gid: string | null;
gadmRow: any | null;
}
export async function resolveGadmHierarchy(lat: number, lng: number): Promise<GadmRow[]> {
const rows: GadmRow[] = [];
try {
const foundRows = await fetchRegionHierarchy(lat, lng);
for (let level = 0; level <= 5; level++) {
const match = foundRows.find((r: any) => r.level === level);
if (match) {
rows.push({ level, queryName: match.gadmName || '', gadmName: match.gadmName, gid: match.gid, gadmRow: match });
} else {
rows.push({ level, queryName: '', gadmName: null, gid: null, gadmRow: null });
}
}
} catch (e) {
for (let level = 0; level <= 5; level++) rows.push({ level, queryName: '', gadmName: null, gid: null, gadmRow: null });
}
return rows;
}

View File

@ -106,11 +106,21 @@ export function RegionLayers({
type: 'circle',
source: 'ghs-centers',
paint: {
'circle-radius': ['match', ['get', 'type'], 'pop', 5, 'built', 4, 3],
'circle-radius': [
'case',
['==', ['get', 'isMain'], true],
['match', ['get', 'type'], 'pop', 7, 'built', 6, 5],
['match', ['get', 'type'], 'pop', 4, 'built', 3, 3]
],
'circle-color': ['match', ['get', 'type'], 'pop', '#facc15', 'built', '#f87171', '#aaaaaa'],
'circle-stroke-width': 1,
'circle-stroke-color': '#000000',
'circle-opacity': 0.8
'circle-opacity': [
'case',
['==', ['get', 'isMain'], true],
0.9,
0.7
]
}
});
}
@ -171,14 +181,32 @@ export function RegionLayers({
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: f.properties.ghsBuiltCenter },
properties: { type: 'built' }
properties: { type: 'built', isMain: true }
});
}
if (f.properties?.ghsPopCenter) {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: f.properties.ghsPopCenter },
properties: { type: 'pop' }
properties: { type: 'pop', isMain: true }
});
}
if (Array.isArray(f.properties?.ghsBuiltCenters)) {
f.properties.ghsBuiltCenters.forEach((c: number[]) => {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c[0], c[1]] },
properties: { type: 'built', isMain: false, value: c[2] }
});
});
}
if (Array.isArray(f.properties?.ghsPopCenters)) {
f.properties.ghsPopCenters.forEach((c: number[]) => {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [c[0], c[1]] },
properties: { type: 'pop', isMain: false, value: c[2] }
});
});
}
});

View File

@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import maplibregl from 'maplibre-gl';
import { Loader2, X, MapPin, Crosshair, Plus, ChevronRight, Copy, Download, Upload, Trash2 } from 'lucide-react';
import * as turf from '@turf/turf';
import { searchGadmRegions, fetchRegionHierarchy, fetchRegionBoundary } from '../client-searches';
import { searchGadmRegions, fetchRegionHierarchy, fetchRegionBoundary, resolveGadmHierarchy, type GadmRow } from '../client-searches';
import { GadmSearchControls } from './components/GadmSearchControls';
import { Label } from '@/components/ui/label';
@ -39,15 +39,7 @@ export interface GadmRegion {
raw?: any;
}
export interface GadmRow {
level: number;
queryName: string;
gadmName: string | null;
gid: string | null;
gadmRow: any | null;
}
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3333';
function createMarkerEl(): HTMLElement {
const el = document.createElement('div');
@ -56,29 +48,19 @@ function createMarkerEl(): HTMLElement {
return el;
}
async function resolveGadmHierarchy(lat: number, lng: number): Promise<GadmRow[]> {
const rows: GadmRow[] = [];
try {
const url = `${API_URL}/api/regions/hierarchy?lat=${lat}&lon=${lng}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
const foundRows = data.data || [];
for (let level = 0; level <= 5; level++) {
const match = foundRows.find((r: any) => r.level === level);
if (match) {
rows.push({ level, queryName: match.gadmName || '', gadmName: match.gadmName, gid: match.gid, gadmRow: match });
} else {
rows.push({ level, queryName: '', gadmName: null, gid: null, gadmRow: null });
}
}
} else {
for (let level = 0; level <= 5; level++) rows.push({ level, queryName: '', gadmName: null, gid: null, gadmRow: null });
}
} catch (e) {
for (let level = 0; level <= 5; level++) rows.push({ level, queryName: '', gadmName: null, gid: null, gadmRow: null });
}
return rows;
const MAX_DISPLAY_LEVEL: Record<number, number> = {
0: 2,
1: 3,
2: 4,
3: 5,
4: 5,
5: 5
};
function getOriginalLevel(gid: string): number {
if (!gid) return 0;
const base = gid.split('_')[0];
return base.split('.').length - 1;
}
export interface GadmPickerProps {
@ -104,7 +86,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const [levelOption, setLevelOption] = useLocalStorage<number>('pm_gadm_levelOption', 0);
const [resolutionOption, setResolutionOption] = useLocalStorage<number>('pm_gadm_resolutionOption', 1);
const enrich = true;
// UI state
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<any[]>([]);
@ -120,21 +102,24 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const [selectedRegions, setSelectedRegions] = useState<GadmRegion[]>([]);
const [geojsons, setGeojsons] = useState<Record<string, any>>({});
// Extracted Inspector State
const markerRef = useRef<maplibregl.Marker | null>(null);
const [inspectedHierarchy, setInspectedHierarchy] = useState<any[] | null>(null);
const [inspectedPoint, setInspectedPoint] = useState<{lat: number, lng: number} | null>(null);
const [inspectedPoint, setInspectedPoint] = useState<{ lat: number, lng: number } | null>(null);
const [inspectedGeojson, setInspectedGeojson] = useState<any>(null);
const [loadingInspector, setLoadingInspector] = useState(false);
const [loadingHighlightGid, setLoadingHighlightGid] = useState<string | null>(null);
const [pendingSelectionCount, setPendingSelectionCount] = useState(0);
const isFetchingSelectionRef = useRef(false);
const [loadingBoundaryIds, setLoadingBoundaryIds] = useState<Set<string>>(new Set());
const loadingBoundaryIdsRef = useRef<Set<string>>(new Set());
const processingGids = useRef<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement>(null);
const importedInitialRef = useRef<string | null>(null);
// Ctrl+click inspection queue logic
const inspectionIdRef = useRef(0);
const queuedInspectionsRef = useRef<Set<number>>(new Set());
@ -162,7 +147,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
}, 50);
}
}
} catch(e) { console.error('Failed to parse cached gadm regions', e); }
} catch (e) { console.error('Failed to parse cached gadm regions', e); }
return () => { mounted = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -173,7 +158,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const polygons = selectedRegions.map(r => geojsons[r.gid]).filter(Boolean);
onSelectionChange(selectedRegions, polygons);
}
// Persist to local storage
if (selectedRegions.length > 0) {
const data = selectedRegions.map(r => ({ gid: r.gid, name: r.gadmName, level: r.level, raw: r.raw }));
@ -220,7 +205,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
// Setup map source/layers
useEffect(() => {
if (!map) return;
const setupMapLayers = () => {
if (!map.getStyle()) return;
if (!map.getSource('gadm-picker-features')) {
@ -263,7 +248,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
if (!markerRef.current) {
markerRef.current = new maplibregl.Marker({ element: createMarkerEl(), draggable: true, anchor: 'bottom' });
markerRef.current.on('dragend', async () => {
if (!markerRef.current) return;
const { lat, lng } = markerRef.current.getLngLat();
@ -276,72 +261,84 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const id = ++inspectionIdRef.current;
if (ctrlKey) {
queuedInspectionsRef.current.add(id);
isFetchingSelectionRef.current = true;
setPendingSelectionCount(p => p + 1);
} else {
queuedInspectionsRef.current.clear();
}
if (!markerRef.current) return;
markerRef.current.setLngLat([lng, lat]);
try {
markerRef.current.addTo(map!);
} catch (e) {}
if (id === inspectionIdRef.current) {
setInspectedPoint({ lat, lng });
setLoadingInspector(true);
setInspectedHierarchy(null);
}
if (!markerRef.current) return;
markerRef.current.setLngLat([lng, lat]);
let rows: any[] | null = null;
try {
rows = await resolveGadmHierarchy(lat, lng);
} catch (e) {
console.error(e);
}
try {
markerRef.current.addTo(map!);
} catch (e) { }
if (ctrlKey) {
if (!queuedInspectionsRef.current.has(id)) return; // Aborted by a newer normal click
queuedInspectionsRef.current.delete(id);
} else {
if (id !== inspectionIdRef.current) return; // Aborted by a newer click
}
if (id === inspectionIdRef.current) {
setInspectedPoint({ lat, lng });
setLoadingInspector(true);
setInspectedHierarchy(null);
}
if (id === inspectionIdRef.current) {
setLoadingInspector(false);
if (rows) setInspectedHierarchy(rows);
}
let rows: any[] | null = null;
try {
rows = await resolveGadmHierarchy(lat, lng);
} catch (e) {
console.error(e);
}
if (rows) {
const { levelOption } = stateRef.current;
const targetRow = rows.find((r: any) => r.level === levelOption) || rows[rows.length - 1];
if (targetRow && targetRow.gid) {
if (id === inspectionIdRef.current) {
setLoadingHighlightGid(targetRow.gid);
}
try {
const { levelOption: currentLevelOpt, resolutionOption } = stateRef.current;
const previewLevel = (resolutionOption > targetRow.level) ? resolutionOption : targetRow.level;
if (ctrlKey) {
if (!queuedInspectionsRef.current.has(id)) return; // Aborted by a newer normal click
queuedInspectionsRef.current.delete(id);
} else {
if (id !== inspectionIdRef.current) return; // Aborted by a newer click
}
const geojson = await fetchRegionBoundary(targetRow.gid, targetRow.gadmName, previewLevel, false);
if (id === inspectionIdRef.current) {
setLoadingInspector(false);
if (rows) setInspectedHierarchy(rows);
}
if (rows) {
const { levelOption } = stateRef.current;
const targetRow = rows.find((r: any) => r.level === levelOption) || rows[rows.length - 1];
if (targetRow && targetRow.gid) {
if (id === inspectionIdRef.current) {
setInspectedGeojson(geojson);
setLoadingHighlightGid(targetRow.gid);
}
try {
const { levelOption: currentLevelOpt, resolutionOption } = stateRef.current;
const previewLevel = (resolutionOption > targetRow.level) ? resolutionOption : targetRow.level;
if (ctrlKey) {
handleSelectRegion({ ...targetRow.gadmRow, level: targetRow.level }, undefined, true);
const maxDisplay = MAX_DISPLAY_LEVEL[targetRow.level] ?? 5;
const displayPreviewLevel = Math.min(previewLevel, maxDisplay);
const geojson = await fetchRegionBoundary(targetRow.gid, targetRow.gadmName, displayPreviewLevel, false);
if (id === inspectionIdRef.current) {
setInspectedGeojson(geojson);
}
if (ctrlKey) {
handleSelectRegion({ ...targetRow.gadmRow, level: targetRow.level }, undefined, true);
}
} catch (e) {
console.error('Failed to fetch boundary for highlight', e);
if (id === inspectionIdRef.current) setInspectedGeojson(null);
} finally {
if (id === inspectionIdRef.current) setLoadingHighlightGid(null);
}
} catch (e) {
console.error('Failed to fetch boundary for highlight', e);
} else {
if (id === inspectionIdRef.current) setInspectedGeojson(null);
} finally {
if (id === inspectionIdRef.current) setLoadingHighlightGid(null);
}
} else {
if (id === inspectionIdRef.current) setInspectedGeojson(null);
}
} else {
if (id === inspectionIdRef.current) setInspectedGeojson(null);
} finally {
if (ctrlKey) {
isFetchingSelectionRef.current = false;
setPendingSelectionCount(p => Math.max(0, p - 1));
}
}
};
@ -357,16 +354,18 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
// Map click handler when active
useEffect(() => {
if (!map || !active) return;
const handleMapClick = async (e: maplibregl.MapMouseEvent) => {
const ctrlKey = e.originalEvent.ctrlKey || e.originalEvent.metaKey;
if (ctrlKey && isFetchingSelectionRef.current) return;
const lat = e.lngLat.lat;
const lng = e.lngLat.lng;
await performInspection(lat, lng, e.originalEvent.ctrlKey || e.originalEvent.metaKey);
await performInspection(lat, lng, ctrlKey);
};
map.getCanvas().style.cursor = 'crosshair';
map.on('click', handleMapClick);
return () => {
map.getCanvas().style.cursor = '';
map.off('click', handleMapClick);
@ -414,19 +413,29 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
setLoadingBoundaryIds(new Set(loadingBoundaryIdsRef.current));
};
const handleSelectRegion = async (region: any, forceLevel?: number, isMulti: boolean = false) => {
async function handleSelectRegion(region: any, forceLevel?: number, isMulti: boolean = false) {
const { levelOption, resolutionOption, enrich } = stateRef.current;
const gadmLevel = region.level !== undefined ? region.level : levelOption;
const gadmLevel = region.raw?.level ?? (region.level !== undefined ? region.level : levelOption);
const gid = region.gid || region[`GID_${gadmLevel}`] || region.GID_0;
const name = region.gadmName || region.name || region[`NAME_${gadmLevel}`] || region.NAME_0;
if (!gid) return;
if (!isMulti) {
if (isFetchingSelectionRef.current) return;
isFetchingSelectionRef.current = true;
}
const realGadmLevel = getOriginalLevel(gid);
// Ensure we handle duplicate queries safely
if (processingGids.current.has(gid)) return;
if (processingGids.current.has(gid)) {
if (!isMulti) isFetchingSelectionRef.current = false;
return;
}
processingGids.current.add(gid);
const targetLevel = (forceLevel !== undefined) ? forceLevel : (resolutionOption > gadmLevel ? resolutionOption : gadmLevel);
const targetLevel = (forceLevel !== undefined) ? forceLevel : (resolutionOption > realGadmLevel ? resolutionOption : realGadmLevel);
let isDuplicate = false;
setSelectedRegions(prev => {
@ -443,34 +452,46 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
setLoadingGid(gid, true);
try {
const displayLevel = Math.min(targetLevel, MAX_DISPLAY_LEVEL[realGadmLevel] ?? 5);
let geojson = await fetchRegionBoundary(
gid, name || '', targetLevel, enrich
gid, name || '', displayLevel, enrich
);
if (!geojson.features || geojson.features.length === 0) {
console.warn(`No subdivisions found for ${gid} at level ${targetLevel}. Snapping back to level ${gadmLevel}`);
setSelectedRegions(prev => prev.map(r => r.gid === gid ? { ...r, level: gadmLevel } : r));
geojson = await fetchRegionBoundary(gid, name || '', gadmLevel, enrich);
console.warn(`No subdivisions found for ${gid} at level ${targetLevel} (displayed ${displayLevel}). Snapping back to level ${realGadmLevel}`);
setSelectedRegions(prev => prev.map(r => r.gid === gid ? { ...r, level: realGadmLevel } : r));
geojson = await fetchRegionBoundary(gid, name || '', realGadmLevel, enrich);
}
let activeStats: Record<string, any> | undefined;
if (enrich && geojson.features && geojson.features.length > 0) {
const props = geojson.features[0].properties;
if (props.ghsPopulation !== undefined || props.ghsBuiltWeight !== undefined) {
activeStats = {
ghsBuiltWeight: props.ghsBuiltWeight,
ghsPopulation: props.ghsPopulation
activeStats = {
ghsBuiltWeight: props.ghsBuiltWeight,
ghsPopulation: props.ghsPopulation
};
}
}
setSelectedRegions(prev => prev.map(r => r.gid === gid ? { ...r, stats: activeStats } : r));
setGeojsons(prev => ({ ...prev, [gid]: geojson }));
if (!isMulti && map && geojson && geojson.features && geojson.features.length > 0) {
try {
const bbox = turf.bbox(geojson as any);
map.fitBounds(bbox as any, { padding: 50, duration: 800 });
} catch (e) {
console.error("Failed to auto-fit bounds", e);
}
}
} catch (e) {
console.error("Failed to fetch boundary for", gid, e);
} finally {
setLoadingGid(gid, false);
if (!isMulti) isFetchingSelectionRef.current = false;
}
};
@ -488,7 +509,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
if (!map) return;
const gj = geojsons[gid];
if (!gj || !gj.features || gj.features.length === 0) return;
try {
const turf = await import('@turf/turf');
const bbox = turf.bbox(gj as any);
@ -502,7 +523,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
if (loadingBoundaryIds.has(region.gid)) return;
if (targetLevel === region.level) return;
handleRemoveRegion(region.gid);
handleSelectRegion({ gid: region.gid, gadmName: region.gadmName, level: region.level }, targetLevel, true);
const originalRaw = region.raw || { gid: region.gid, gadmName: region.gadmName, level: region.level };
handleSelectRegion(originalRaw, targetLevel, true);
};
const handleCopyToClipboard = async () => {
@ -543,7 +565,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
// Queue them all concurrently via isMulti=true
for (const item of parsed) {
const raw = item.raw || { gid: item.gid, gadmName: item.name, level: item.level };
handleSelectRegion(raw, undefined, true);
handleSelectRegion(raw, item.level, true);
}
}
} catch (err) {
@ -585,8 +607,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
setGeojsons({});
for (const item of initialRegions) {
const raw = { gid: item.gid, gadmName: item.name, level: item.level };
handleSelectRegion(raw, undefined, true);
const raw = (item as any).raw || { gid: item.gid, gadmName: item.name, level: item.level };
handleSelectRegion(raw, item.level, true);
}
}, [initialRegions]);
@ -622,7 +644,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
<h4 className="text-xs font-semibold uppercase tracking-wider">Inspected Location</h4>
{loadingInspector && <Loader2 className="w-3 h-3 animate-spin ml-auto" />}
</div>
{!loadingInspector && inspectedHierarchy && (
<div className="flex flex-col gap-1.5 mt-2">
{inspectedHierarchy.map((row) => (
@ -634,7 +656,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
<ChevronRight className="w-3 h-3 text-sky-600 dark:text-sky-400" />
</div>
))}
{/* Name / Action */}
{row.gid ? (
<div className="flex items-center flex-1 min-w-0 bg-white dark:bg-sky-900/30 border border-sky-100 dark:border-sky-800 rounded px-2 py-1 transition-colors">
@ -645,7 +667,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
</span>
<span className="ml-1.5 text-[10px] text-sky-500 dark:text-sky-400 font-normal opacity-70 flex-shrink-0">L{row.level}</span>
</div>
<button
<button
onClick={(e) => handleSelectRegion(row.gadmRow, row.level, e.ctrlKey || e.metaKey)}
className="ml-auto text-[10px] font-bold tracking-wide uppercase bg-sky-100 hover:bg-sky-200 dark:bg-sky-800 dark:hover:bg-sky-700 text-sky-700 dark:text-sky-200 px-2 py-0.5 rounded transition-colors flex items-center gap-1 flex-shrink-0"
title="Add this region"
@ -696,9 +718,12 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
<div className="flex flex-col gap-2">
{selectedRegions.map((region) => {
const loading = loadingBoundaryIds.has(region.gid);
const realGadm = getOriginalLevel(region.gid);
const maxDisplay = MAX_DISPLAY_LEVEL[realGadm] ?? 5;
const isCapped = region.level > maxDisplay;
return (
<div
key={region.gid}
<div
key={region.gid}
className="flex flex-col bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg p-2 cursor-pointer hover:bg-yellow-100 dark:hover:bg-yellow-900/40 transition-colors"
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) return;
@ -707,12 +732,12 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
>
<div className="flex items-center gap-1.5 justify-between">
<div className="flex items-center gap-1.5 font-medium text-yellow-800 dark:text-yellow-300 text-sm">
{loading && <Loader2 className="w-3 h-3 animate-spin text-yellow-600"/>}
{loading && <Loader2 className="w-3 h-3 animate-spin text-yellow-600" />}
{!loading && <span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0"></span>}
<span className="truncate">{region.gadmName}</span>
<span className="truncate">{region.gadmName}</span>
<span className="text-xs opacity-70">({region.gid})</span>
</div>
<button
<button
onClick={(e) => { e.stopPropagation(); handleRemoveRegion(region.gid); }}
className="text-yellow-600 hover:text-red-500 flex-shrink-0 focus:outline-none ml-2 bg-yellow-100/50 dark:bg-yellow-800/30 p-1 rounded transition-colors"
>
@ -725,6 +750,12 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
<span><strong className="font-semibold">Pop:</strong> {region.stats.ghsPopulation ? Math.round(region.stats.ghsPopulation).toLocaleString() : 'N/A'}</span>
</div>
)}
{isCapped && !loading && (
<div className="mt-1.5 text-[10px] text-amber-600 dark:text-amber-400 flex items-center gap-1 pl-3.5 opacity-80" title={`Target is L${region.level} but map shows max L${maxDisplay} for performance. Simulator uses full L${region.level} data.`}>
<svg className="w-3 h-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
<span>Map capped at <strong>L{maxDisplay}</strong> simulator uses full <strong>L{region.level}</strong></span>
</div>
)}
<div className="mt-2 flex items-center justify-between border-t border-yellow-200/50 dark:border-yellow-800/50 pt-2 px-1">
<span className="text-[10px] uppercase font-bold text-yellow-600/70 dark:text-yellow-500/70 tracking-wider">Change Level</span>
<div className="flex items-center bg-yellow-100/50 dark:bg-yellow-900/30 p-0.5 rounded border border-yellow-200 dark:border-yellow-700/50">
@ -734,11 +765,10 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
<button
key={lvl}
onClick={(e) => { e.stopPropagation(); handleReResolveRegion(region, lvl); }}
className={`px-2 py-0.5 text-[10px] font-bold rounded transition-colors ${
isSelected
? 'bg-yellow-400 text-yellow-900 shadow-sm'
: 'text-yellow-700 dark:text-yellow-400 hover:bg-yellow-200/50 dark:hover:bg-yellow-800/50'
}`}
className={`px-2 py-0.5 text-[10px] font-bold rounded transition-colors ${isSelected
? 'bg-yellow-400 text-yellow-900 shadow-sm'
: 'text-yellow-700 dark:text-yellow-400 hover:bg-yellow-200/50 dark:hover:bg-yellow-800/50'
}`}
title={`Change to Level ${lvl}`}
>
L{lvl}
@ -750,13 +780,38 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
</div>
);
})}
{/* Optimistic pending selection placeholders */}
{Array.from({ length: pendingSelectionCount }).map((_, i) => (
<div key={`pending-${i}`} className="flex flex-col bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg p-2 opacity-70">
<div className="flex items-center gap-2 font-medium text-yellow-800 dark:text-yellow-300 text-sm">
<Loader2 className="w-3 h-3 animate-spin text-yellow-600" />
<span>Resolving location...</span>
</div>
</div>
))}
</div>
</div>
) : (
!inspectedPoint && (
<div className="text-sm text-gray-500 italic text-center py-4 bg-gray-50 dark:bg-gray-800/50 rounded border border-dashed border-gray-200 dark:border-gray-700">
No regions picked yet.
pendingSelectionCount > 0 ? (
<div className="space-y-2 pb-2">
<div className="flex flex-col gap-2">
{Array.from({ length: pendingSelectionCount }).map((_, i) => (
<div key={`pending-empty-${i}`} className="flex flex-col bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg p-2 opacity-70">
<div className="flex items-center gap-2 font-medium text-yellow-800 dark:text-yellow-300 text-sm">
<Loader2 className="w-3 h-3 animate-spin text-yellow-600" />
<span>Resolving location...</span>
</div>
</div>
))}
</div>
</div>
) : (
!inspectedPoint && (
<div className="text-sm text-gray-500 italic text-center py-4 bg-gray-50 dark:bg-gray-800/50 rounded border border-dashed border-gray-200 dark:border-gray-700">
No regions picked yet.
</div>
)
)
)}
</div>
@ -766,7 +821,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
return (
<>
{sidebarContent}
{/* Portal Config & Search to Header */}
{headerPortalTarget && createPortal(
<GadmSearchControls

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import * as turf from '@turf/turf';
import { generateGridSearchCells, extractGridWaypoints, SimulatorGridCell, GridSearchHop, GridFeature } from '@polymech/shared';
import { GridSimulatorSettings, GridSearchSimulatorProps } from '../types';
import { fetchRegionBoundary } from '../../../client-searches';
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
@ -148,12 +149,20 @@ export function useGridSimulatorState({
return;
}
// Fetch precisely the correct targetLevel resolution boundaries
// instead of relying on pickerPolygons which might be capped for display.
const features: GridFeature[] = [];
pickerPolygons.forEach(fc => {
if (fc && fc.features) {
features.push(...(fc.features as GridFeature[]));
for (const region of pickerRegions) {
const geojson = await fetchRegionBoundary(
region.gid,
region.gadmName || '',
region.level,
true // enrich
);
if (geojson && geojson.features) {
features.push(...(geojson.features as GridFeature[]));
}
});
}
if (features.length === 0) return;
setIsCalculating(true);