Maintenance Love :)
This commit is contained in:
parent
021dc7b0c3
commit
1b5bf73320
@ -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);
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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] }
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user