From 1b5bf73320f69ba4476c048cedaa181dd8387c64 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 24 Mar 2026 11:00:01 +0100 Subject: [PATCH] Maintenance Love :) --- .../src/products/places/grid-generator.ts | 38 ++- .../src/modules/places/CompetitorsMapView.tsx | 34 +- .../ui/src/modules/places/client-searches.ts | 26 ++ .../components/map-layers/RegionLayers.tsx | 36 ++- .../modules/places/gadm-picker/GadmPicker.tsx | 305 +++++++++++------- .../simulator/hooks/useGridSimulatorState.ts | 17 +- 6 files changed, 310 insertions(+), 146 deletions(-) diff --git a/packages/ui/shared/src/products/places/grid-generator.ts b/packages/ui/shared/src/products/places/grid-generator.ts index 35cc281d..5066878d 100644 --- a/packages/ui/shared/src/products/places/grid-generator.ts +++ b/packages/ui/shared/src/products/places/grid-generator.ts @@ -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(); + 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); diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index 91c822f2..dd0071eb 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -155,6 +155,8 @@ export const CompetitorsMapView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ competit `} {/* Split View Container */} -
+
{/* Left Tools Sidebar */} {(gadmPickerActive || simulatorActive) && ( -
+ <> +
{ @@ -424,6 +442,14 @@ export const CompetitorsMapView: React.FC = ({ competit
+ {/* Drag Handle for Left Sidebar */} +
+ +
+ )} {/* Map Panel & Center Layout */} diff --git a/packages/ui/src/modules/places/client-searches.ts b/packages/ui/src/modules/places/client-searches.ts index e16cf379..047f5cc9 100644 --- a/packages/ui/src/modules/places/client-searches.ts +++ b/packages/ui/src/modules/places/client-searches.ts @@ -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 { + 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; +} diff --git a/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx b/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx index 0d6427d8..8558301a 100644 --- a/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx @@ -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] } + }); }); } }); diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index 37bbbc1c..51326c85 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -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 { - 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 = { + 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('pm_gadm_levelOption', 0); const [resolutionOption, setResolutionOption] = useLocalStorage('pm_gadm_resolutionOption', 1); const enrich = true; - + // UI state const [query, setQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); @@ -120,21 +102,24 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className const [selectedRegions, setSelectedRegions] = useState([]); const [geojsons, setGeojsons] = useState>({}); - + // Extracted Inspector State const markerRef = useRef(null); const [inspectedHierarchy, setInspectedHierarchy] = useState(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(null); const [loadingInspector, setLoadingInspector] = useState(false); const [loadingHighlightGid, setLoadingHighlightGid] = useState(null); + const [pendingSelectionCount, setPendingSelectionCount] = useState(0); + const isFetchingSelectionRef = useRef(false); + const [loadingBoundaryIds, setLoadingBoundaryIds] = useState>(new Set()); const loadingBoundaryIdsRef = useRef>(new Set()); const processingGids = useRef>(new Set()); const fileInputRef = useRef(null); const importedInitialRef = useRef(null); - + // Ctrl+click inspection queue logic const inspectionIdRef = useRef(0); const queuedInspectionsRef = useRef>(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 | 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

Inspected Location

{loadingInspector && }
- + {!loadingInspector && inspectedHierarchy && (
{inspectedHierarchy.map((row) => ( @@ -634,7 +656,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
))} - + {/* Name / Action */} {row.gid ? (
@@ -645,7 +667,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className L{row.level}
-
)} + {isCapped && !loading && ( +
+ + Map capped at L{maxDisplay} — simulator uses full L{region.level} +
+ )}
Change Level
@@ -734,11 +765,10 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
); })} + + {/* Optimistic pending selection placeholders */} + {Array.from({ length: pendingSelectionCount }).map((_, i) => ( +
+
+ + Resolving location... +
+
+ ))}
) : ( - !inspectedPoint && ( -
- No regions picked yet. + pendingSelectionCount > 0 ? ( +
+
+ {Array.from({ length: pendingSelectionCount }).map((_, i) => ( +
+
+ + Resolving location... +
+
+ ))} +
+ ) : ( + !inspectedPoint && ( +
+ No regions picked yet. +
+ ) ) )}
@@ -766,7 +821,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className return ( <> {sidebarContent} - + {/* Portal Config & Search to Header */} {headerPortalTarget && createPortal( (key: string, initialValue: T) { const [storedValue, setStoredValue] = useState(() => { @@ -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);