diff --git a/packages/ui/shared/src/products/places/grid-generator.ts b/packages/ui/shared/src/products/places/grid-generator.ts index 1822d9a2..fc5013d8 100644 --- a/packages/ui/shared/src/products/places/grid-generator.ts +++ b/packages/ui/shared/src/products/places/grid-generator.ts @@ -105,7 +105,7 @@ function checkCellFilters(props: GridFeatureProperties, options: GridGeneratorOp const checkPop = minGhsPop > 0; const checkBuilt = minGhsBuilt > 0; - + if (checkPop || checkBuilt) { const hasPopData = props.ghsPopulation !== undefined; const hasBuiltData = props.ghsBuiltWeight !== undefined; @@ -144,10 +144,10 @@ function checkSkipPolygons(pt: Feature, skipPolygons?: Feature[] = []; - console.log(`[GHS Centers] Total features: ${features.length}`); for (let i = 0; i < features.length; i++) { const f = features[i]; const props = f.properties || {}; @@ -347,7 +346,7 @@ async function generateCenterCells( 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])); } @@ -356,15 +355,15 @@ async function generateCenterCells( } const uniqueCenters = Array.from(centersMap.values()); - console.log(`[GHS Centers] Feature ${i} (${props.NAME_2 || props.NAME_1 || props.name || '?'}): popCenter=${props.ghsPopCenter ? 'yes' : 'no'}, builtCenter=${props.ghsBuiltCenter ? 'yes' : 'no'}, popCenters=${Array.isArray(props.ghsPopCenters) ? props.ghsPopCenters.length : 'none'}, builtCenters=${Array.isArray(props.ghsBuiltCenters) ? props.ghsBuiltCenters.length : 'none'}, unique=${uniqueCenters.length}`); + //console.log(`[GHS Centers] Feature ${i} (${props.NAME_2 || props.NAME_1 || props.name || '?'}): popCenter=${props.ghsPopCenter ? 'yes' : 'no'}, builtCenter=${props.ghsBuiltCenter ? 'yes' : 'no'}, popCenters=${Array.isArray(props.ghsPopCenters) ? props.ghsPopCenters.length : 'none'}, builtCenters=${Array.isArray(props.ghsBuiltCenters) ? props.ghsBuiltCenters.length : 'none'}, unique=${uniqueCenters.length}`); for (let j = 0; j < uniqueCenters.length; j++) { const { coord, popWeight, builtWeight } = uniqueCenters[j]; const pt = turf.point(coord); const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); - - const centerProps = { - ...props, + + const centerProps = { + ...props, ghsPopulation: popWeight !== undefined ? popWeight : props.ghsPopulation, ghsBuiltWeight: builtWeight !== undefined ? builtWeight : props.ghsBuiltWeight }; @@ -437,7 +436,7 @@ async function generatePolygonGrid( }; } - const grid = gridMode === 'square' + const grid = gridMode === 'square' ? turf.squareGrid(bbox, cellSize, { units: 'kilometers' }) : turf.hexGrid(bbox, cellSize, { units: 'kilometers' }); @@ -473,7 +472,7 @@ async function generatePolygonGrid( const regionFeature = features[regionIndex]; const props = regionFeature?.properties || {}; const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(regionFeature) / 1000000); - + let { allowed, reason } = checkCellFilters(props, options, areaSqKm); if (allowed && onFilterCell && !onFilterCell(cell)) { allowed = false; @@ -514,7 +513,7 @@ export async function generateGridSearchCells( options: GridGeneratorOptions, onProgress?: (stats: GridGeneratorProgressStats) => Promise ): Promise { - + let result: GridGeneratorResult; if (options.gridMode === 'admin') { @@ -532,10 +531,10 @@ export async function generateGridSearchCells( // Sort valid cells according to paths if (result.validCells.length > 0) { result.validCells = sortGridCells( - result.validCells, - options.pathOrder, - options.cellSize, - options.groupByRegion, + result.validCells, + options.pathOrder, + options.cellSize, + options.groupByRegion, options.features.length ); } diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index 463857c9..5764fff4 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react' import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid, X } from 'lucide-react'; import { RulerButton } from './components/RulerButton'; import { type CompetitorFull } from '@polymech/shared'; +import { fetchRegionBoundary } from './client-searches'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useTheme } from '@/components/ThemeProvider'; @@ -17,6 +18,7 @@ import { MAP_STYLES, type MapStyleKey } from './components/map-styles'; import { SimulatorLayers } from './components/map-layers/SimulatorLayers'; import { RegionLayers } from './components/map-layers/RegionLayers'; import { MapLayerToggles } from './components/MapLayerToggles'; +import { MapOverlayToolbars } from './components/MapOverlayToolbars'; // import { useLocationEnrichment } from './hooks/useEnrichment'; const safeSetStyle = (m: maplibregl.Map, style: any) => { @@ -36,7 +38,52 @@ const safeSetStyle = (m: maplibregl.Map, style: any) => { +export type MapPreset = 'SearchView' | 'Minimal'; + +export interface MapFeatures { + enableSidebarTools: boolean; + enableRuler: boolean; + enableEnrichment: boolean; + enableLayerToggles: boolean; + enableInfoPanel: boolean; + enableLocationDetails: boolean; + enableAutoRegions: boolean; + enableSimulator: boolean; + showSidebar?: boolean; + canDebug?: boolean; + canPlaybackSpeed?: boolean; +} + +export const MAP_PRESETS: Record = { + SearchView: { + enableSidebarTools: true, + enableRuler: true, + enableEnrichment: true, + enableLayerToggles: true, + enableInfoPanel: true, + enableLocationDetails: true, + enableAutoRegions: false, + enableSimulator: false, + canDebug: true, + canPlaybackSpeed: true, + }, + Minimal: { + enableSidebarTools: true, // hasTools (minimal = true) + enableRuler: false, + enableEnrichment: false, + enableLayerToggles: false, + enableInfoPanel: false, + enableLocationDetails: true, + enableAutoRegions: true, + enableSimulator: true, + canDebug: false, + canPlaybackSpeed: false, + } +}; + interface CompetitorsMapViewProps { + preset?: MapPreset; + customFeatures?: Partial; competitors: CompetitorFull[]; onMapCenterUpdate: (loc: string, zoom?: number) => void; initialCenter?: { lat: number, lng: number }; @@ -118,7 +165,14 @@ const renderPopupHtml = (competitor: CompetitorFull) => { `; }; -export const CompetitorsMapView: React.FC = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings }) => { +export const CompetitorsMapView: React.FC = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, preset = 'SearchView', customFeatures }) => { + const features: MapFeatures = useMemo(() => { + return { + ...MAP_PRESETS[preset], + ...customFeatures + }; + }, [preset, customFeatures]); + const mapContainer = useRef(null); const mapWrapper = useRef(null); const map = useRef(null); @@ -142,6 +196,7 @@ export const CompetitorsMapView: React.FC = ({ competit // Selection and Sidebar State const [selectedLocation, setSelectedLocation] = useState(null); const [gadmPickerActive, setGadmPickerActive] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(features.showSidebar ?? false); // Grid Search Simulator State const [simulatorActive, setSimulatorActive] = useState(false); @@ -165,6 +220,66 @@ export const CompetitorsMapView: React.FC = ({ competit const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(onMapCenterUpdate); + // Auto-load GADM region boundaries when enableAutoRegions is on + const autoRegionsLoadedRef = useRef(false); + useEffect(() => { + if (!features.enableAutoRegions || !initialGadmRegions || initialGadmRegions.length === 0 || autoRegionsLoadedRef.current) return; + autoRegionsLoadedRef.current = true; + + (async () => { + const regions: any[] = []; + const polygons: any[] = []; + for (const region of initialGadmRegions) { + regions.push(region); + try { + const geojson = await fetchRegionBoundary(region.gid, region.name, region.level, true); + if (geojson) polygons.push(geojson); + } catch (err) { + console.error('Failed to fetch boundary for', region.gid, err); + } + } + setPickerRegions(regions); + setPickerPolygons(polygons); + })(); + }, [features.enableAutoRegions, initialGadmRegions]); + + // Fit map to loaded region boundaries (waits for map readiness) + const hasFittedBoundsRef = useRef(false); + useEffect(() => { + if (hasFittedBoundsRef.current || pickerPolygons.length === 0) return; + + const fitToPolygons = () => { + if (!map.current) return false; + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; + pickerPolygons.forEach(fc => { + fc?.features?.forEach((f: any) => { + const coords = f.geometry?.coordinates; + if (!coords) return; + if (f.geometry.type === 'MultiPolygon') { + coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; })); + } else if (f.geometry.type === 'Polygon') { + coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }); + } + }); + }); + if (hasPoints && !bounds.isEmpty()) { + map.current.fitBounds(bounds, { padding: 50, maxZoom: 10 }); + hasFittedBoundsRef.current = true; + return true; + } + return false; + }; + + // Try immediately, then poll until map is ready + if (fitToPolygons()) return; + const interval = setInterval(() => { + if (fitToPolygons()) clearInterval(interval); + }, 200); + const timeout = setTimeout(() => clearInterval(interval), 5000); + return () => { clearInterval(interval); clearTimeout(timeout); }; + }, [pickerPolygons]); + // Enrichment Hook - NOW PASSED VIA PROPS // const { enrich, isEnriching, progress: enrichmentProgress } = useLocationEnrichment(); @@ -381,17 +496,17 @@ export const CompetitorsMapView: React.FC = ({ competit `} {/* Split View Container */} -
+
{/* Left Tools Sidebar */} - {(gadmPickerActive || simulatorActive) && ( + {(features.enableSidebarTools || sidebarOpen) && ( <>
{ setGadmPickerActive(val === 'gadm'); setSimulatorActive(val === 'simulator'); @@ -404,7 +519,7 @@ export const CompetitorsMapView: React.FC = ({ competit Grid Search - - {/* Info Button */} - -
- -
- -
{ /* Spacer */} -
- -
-
-
-
+ {/* Header: Search Region & Tools Overlay */} + {/* Map Viewport */}
@@ -521,11 +603,27 @@ export const CompetitorsMapView: React.FC = ({ competit map={map.current} isDarkStyle={mapStyleKey === 'dark'} pickerPolygons={pickerPolygons} + polygonsFeatureCollection={pickerPolygons.length > 0 ? { type: 'FeatureCollection', features: pickerPolygons.flatMap(fc => fc?.features || []) } : undefined} showDensity={showDensity} showCenters={showCenters} /> )} + + {/* Hidden simulator for portal-based play controls in Minimal mode */} + {features.enableSimulator && !features.enableSidebarTools && !sidebarOpen && pickerRegions.length > 0 && ( +
+ true} + initialSettings={{ gridMode: 'centers', cellSize: 50 }} + /> +
+ )}
{/* Footer: Status Info & Toggles */} @@ -535,33 +633,57 @@ export const CompetitorsMapView: React.FC = ({ competit isLocating={isLocating} onLocate={() => handleLocate(map.current)} onZoomToFit={() => { - if (!map.current || validLocations.length === 0) return; + if (!map.current) return; const bounds = new maplibregl.LngLatBounds(); - validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); - if (!bounds.isEmpty()) { + let hasPoints = false; + + if (validLocations.length > 0) { + validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); + hasPoints = true; + } + + if (pickerPolygons.length > 0) { + pickerPolygons.forEach(fc => { + fc?.features?.forEach((f: any) => { + const coords = f.geometry?.coordinates; + if (!coords) return; + if (f.geometry.type === 'MultiPolygon') { + coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; })); + } else if (f.geometry.type === 'Polygon') { + coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }); + } + }); + }); + } + + if (hasPoints && !bounds.isEmpty()) { map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 }); } }} activeStyleKey={mapStyleKey} onStyleChange={setMapStyleKey} > - - - + {features.enableLayerToggles && ( + + )} + {features.enableRuler && } + {features.enableEnrichment && ( + + )} - {enrichmentProgress && ( + {features.enableEnrichment && enrichmentProgress && (
{enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total}) @@ -571,7 +693,7 @@ export const CompetitorsMapView: React.FC = ({ competit
{/* Resizable Handle and Property Pane */} - {(selectedLocation || infoPanelOpen) && ( + {((features.enableLocationDetails && selectedLocation) || (features.enableInfoPanel && infoPanelOpen)) && ( <> {/* Drag Handle */}
= ({ competit {/* Property Pane */}
- {selectedLocation ? ( + {features.enableLocationDetails && selectedLocation ? ( setSelectedLocation(null)} /> - ) : ( + ) : features.enableInfoPanel && infoPanelOpen ? ( setInfoPanelOpen(false)} @@ -596,7 +718,7 @@ export const CompetitorsMapView: React.FC = ({ competit lng={mapInternals?.lng} locationName={currentCenterLabel} /> - )} + ) : null}
)} diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index aed410d9..b5375e63 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -37,6 +37,15 @@ export interface GridSearchJobPayload { enrichers?: string[]; enrichBudgetMs?: number; jobId?: string; + guided?: { + areas: { + gid: string; + name: string; + level: number; + raw: any; + }[]; + settings?: any; + }; } export interface GridSearchGeneratePayload { diff --git a/packages/ui/src/modules/places/client-searches.ts b/packages/ui/src/modules/places/client-searches.ts index 047f5cc9..17f28c43 100644 --- a/packages/ui/src/modules/places/client-searches.ts +++ b/packages/ui/src/modules/places/client-searches.ts @@ -280,3 +280,31 @@ export async function resolveGadmHierarchy(lat: number, lng: number): Promise => { + // Round to cache key + const cacheKey = `gadm-children-${adminGid}-L${targetLevel}`; + return fetchWithDeduplication(cacheKey, async () => { + const { data: { session } } = await defaultSupabase.auth.getSession(); + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + const url = new URL(`${serverUrl}/api/regions/names`); + url.searchParams.append('admin', adminGid); + url.searchParams.append('content_level', targetLevel.toString()); + url.searchParams.append('resolveNames', 'true'); + + const res = await fetch(url.toString(), { headers }); + if (!res.ok) { + console.error('Failed to fetch region children', await res.text()); + return []; + } + const json = await res.json(); + return json.data || json.rows || []; + }); +}; + diff --git a/packages/ui/src/modules/places/components/MapFooter.tsx b/packages/ui/src/modules/places/components/MapFooter.tsx index 7bbf0e22..454857fb 100644 --- a/packages/ui/src/modules/places/components/MapFooter.tsx +++ b/packages/ui/src/modules/places/components/MapFooter.tsx @@ -28,8 +28,8 @@ export function MapFooter({ children }: MapFooterProps) { return ( -
-
+
+
{currentCenterLabel ? (
diff --git a/packages/ui/src/modules/places/components/MapOverlayToolbars.tsx b/packages/ui/src/modules/places/components/MapOverlayToolbars.tsx new file mode 100644 index 00000000..b0fc6f20 --- /dev/null +++ b/packages/ui/src/modules/places/components/MapOverlayToolbars.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { LayoutGrid, Info } from 'lucide-react'; + +interface MapOverlayToolbarsProps { + features: any; + sidebarOpen: boolean; + gadmPickerActive: boolean; + simulatorActive: boolean; + setSidebarOpen: (v: boolean) => void; + setGadmPickerActive: (v: boolean) => void; + setSimulatorActive: (v: boolean) => void; + infoPanelOpen: boolean; + setInfoPanelOpen: (v: boolean) => void; +} + +export function MapOverlayToolbars({ + features, + sidebarOpen, + gadmPickerActive, + simulatorActive, + setSidebarOpen, + setGadmPickerActive, + setSimulatorActive, + infoPanelOpen, + setInfoPanelOpen +}: MapOverlayToolbarsProps) { + const isExpanded = gadmPickerActive || simulatorActive || sidebarOpen; + + return ( +
+
+ + {/* Tools & Info Buttons */} + {(features.enableSidebarTools || features.showSidebar || features.enableInfoPanel) && ( +
+ {/* Sidebar Toggle Button */} + {(features.enableSidebarTools || features.showSidebar) && ( + + )} + + {/* Info Button */} + {features.enableInfoPanel && ( + + )} +
+ )} + + {/* Portal target for Simulator Controls */} +
+ +
{ /* Spacer */} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx index 106af909..649b223e 100644 --- a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx @@ -143,6 +143,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath if (map.getSource('simulator-grid')) (map.getSource('simulator-grid') as maplibregl.GeoJSONSource).setData(simulatorData || emptyFc as any); if (map.getSource('simulator-path')) (map.getSource('simulator-path') as maplibregl.GeoJSONSource).setData(simulatorPath || emptyFc as any); if (map.getSource('simulator-scanner')) (map.getSource('simulator-scanner') as maplibregl.GeoJSONSource).setData(simulatorScanner || emptyFc as any); + if (map.getLayer('simulator-scanner-pacman')) map.moveLayer('simulator-scanner-pacman'); } catch (e) { console.warn("Could not update simulator data", e); } diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPickedRegions.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPickedRegions.tsx new file mode 100644 index 00000000..8d011086 --- /dev/null +++ b/packages/ui/src/modules/places/gadm-picker/GadmPickedRegions.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { X, Trash2, ChevronRight } from 'lucide-react'; +import type { GadmNode } from './GadmTreePicker'; + +export interface GadmPickedRegionsProps { + selectedNodes: GadmNode[]; + resolutions: Record; + onRemove: (node: GadmNode) => void; + onChangeResolution: (node: GadmNode, level: number) => void; + onClearAll: () => void; +} + +/** Build breadcrumb segments from node hierarchy data */ +function buildBreadcrumbs(node: GadmNode): string[] { + const crumbs: string[] = []; + if (!node.data) return [node.label || node.name]; + for (let i = 0; i <= 5; i++) { + const name = node.data[`NAME_${i}`]; + if (!name) break; + crumbs.push(name); + if (node.data[`GID_${i}`] === node.gid) break; + } + if (crumbs.length === 0) crumbs.push(node.label || node.name); + return crumbs; +} + +export function GadmPickedRegions({ selectedNodes, resolutions, onRemove, onChangeResolution, onClearAll }: GadmPickedRegionsProps) { + if (!selectedNodes || selectedNodes.length === 0) return null; + + return ( +
+
+ + Selected ({selectedNodes.length}) + + {onClearAll && ( + + )} +
+ +
+ {selectedNodes.map((node) => { + const crumbs = buildBreadcrumbs(node); + const currentRes = resolutions[node.gid] ?? node.level; + + return ( +
+ + + {/* Breadcrumbs */} +
+ {crumbs.map((c, i) => ( + + {i > 0 && } + + {c} + + + ))} +
+ + {/* Compact resolution pills */} +
+ {[0, 1, 2, 3, 4, 5].map((l) => ( + + ))} +
+ + +
+ ); + })} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index ebfe979a..b8d566ee 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -134,6 +134,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className // Restore selected Gadm regions on mount useEffect(() => { let mounted = true; + // Commented out to prevent merging old selections during wizard steps + /* try { const saved = window.localStorage.getItem('pm_gadm_saved_regions'); if (saved) { @@ -149,6 +151,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className } } } catch (e) { console.error('Failed to parse cached gadm regions', e); } + */ return () => { mounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -160,13 +163,15 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className onSelectionChange(selectedRegions, polygons); } - // Persist to local storage + // Persist to local storage (disabled) + /* if (selectedRegions.length > 0) { const data = selectedRegions.map(r => ({ gid: r.gid, name: r.gadmName, level: r.level, raw: r.raw })); window.localStorage.setItem('pm_gadm_saved_regions', JSON.stringify(data)); } else { window.localStorage.removeItem('pm_gadm_saved_regions'); } + */ }, [selectedRegions, geojsons]); const updateMapFeatures = useCallback(() => { @@ -343,6 +348,29 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className } }; + const handlePreviewRegion = async (gid: string, name: string, level: number) => { + if (!map) return; + setLoadingHighlightGid(gid); + try { + const { resolutionOption } = stateRef.current; + const previewLevel = (resolutionOption > level) ? resolutionOption : level; + const maxDisplay = MAX_DISPLAY_LEVEL[level] ?? 5; + const displayPreviewLevel = Math.min(previewLevel, maxDisplay); + + const geojson = await fetchRegionBoundary(gid, name, displayPreviewLevel, false); + setInspectedGeojson(geojson); + + if (geojson && geojson.features && geojson.features.length > 0) { + const bbox = turf.bbox(geojson as any); + map.fitBounds(bbox as any, { padding: 50, duration: 800 }); + } + } catch (e) { + console.error('Failed to fetch preview boundary', e); + } finally { + setLoadingHighlightGid(null); + } + }; + // Keep map highlight mapped to inspectedGeojson useEffect(() => { if (!map || !active) return; @@ -682,10 +710,13 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className {/* Name / Action */} {row.gid ? ( -
-
+
+
handlePreviewRegion(row.gid, row.gadmName, row.level)} + > {loadingHighlightGid === row.gid && } - + {row.gadmName} L{row.level} diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPickerContext.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPickerContext.tsx new file mode 100644 index 00000000..3d338eb6 --- /dev/null +++ b/packages/ui/src/modules/places/gadm-picker/GadmPickerContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, useState, useRef } from 'react'; +import type { GadmNode, GadmTreePickerApi, ExpandState } from './GadmTreePicker'; + +export interface GadmPickerState { + roots: GadmNode[]; + setRoots: React.Dispatch>; + expandMap: Record; + setExpandMap: React.Dispatch>>; + regionQuery: string; + setRegionQuery: React.Dispatch>; + hasAutoLocated: boolean; + setHasAutoLocated: React.Dispatch>; + treeApiRef: React.MutableRefObject; +} + +const GadmPickerContext = createContext(null); + +export function GadmPickerProvider({ children }: { children: React.ReactNode }) { + const [roots, setRoots] = useState([]); + const [expandMap, setExpandMap] = useState>({}); + const [regionQuery, setRegionQuery] = useState(''); + const [hasAutoLocated, setHasAutoLocated] = useState(false); + const treeApiRef = useRef(null); + + return ( + + {children} + + ); +} + +export function useGadmPicker(): GadmPickerState { + const ctx = useContext(GadmPickerContext); + if (!ctx) throw new Error('useGadmPicker must be used within GadmPickerProvider'); + return ctx; +} diff --git a/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx new file mode 100644 index 00000000..5c269214 --- /dev/null +++ b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx @@ -0,0 +1,288 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { MapPin, Loader2, Locate } from 'lucide-react'; +import { searchGadmRegions, fetchRegionChildren, fetchRegionHierarchy } from '../client-searches'; +import { GadmTreePicker, GadmNode, GadmTreePickerApi } from './GadmTreePicker'; +import { GadmPickedRegions } from './GadmPickedRegions'; +import { useGadmPicker } from './GadmPickerContext'; + +export interface GadmRegionCollectorProps { + selectedNodes: GadmNode[]; + onSelectionChange: (nodes: GadmNode[]) => void; + resolutions: Record; + onChangeResolution: (gid: string, level: number) => void; +} +export function GadmRegionCollector({ + onSelectionChange, + selectedNodes, + resolutions, + onChangeResolution +}: GadmRegionCollectorProps) { + const { + roots, setRoots, + expandMap, setExpandMap, + regionQuery, setRegionQuery, + hasAutoLocated, setHasAutoLocated, + treeApiRef, + } = useGadmPicker(); + + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + + const suggestionsWrapRef = useRef(null); + const inputRef = useRef(null); + const [isLocating, setIsLocating] = useState(false); + + const handleAddSuggestion = (s: any, keepQuery?: string) => { + // Find the deepest GID available in the search result + let deepestGid = ''; + let deepestName = ''; + let deepestLevel = -1; + for (let i = 5; i >= 0; i--) { + if (s[`GID_${i}`]) { + deepestGid = s[`GID_${i}`]; + deepestName = s[`NAME_${i}`] || s.name || deepestGid; + deepestLevel = i; + break; + } + } + if (!deepestGid) return; + + // Parse the GID to reconstruct the full hierarchy + // GID format: "DEU.14.3_1" → country="DEU", segments=["14","3"], version="_1" + const versionMatch = deepestGid.match(/_\d+$/); + const version = versionMatch ? versionMatch[0] : '_1'; + const withoutVersion = deepestGid.replace(/_\d+$/, ''); + const parts = withoutVersion.split('.'); + const countryCode = parts[0]; // e.g. "DEU" + + // Build path GIDs for each level: DEU, DEU.14_1, DEU.14.3_1 + const pathGids: string[] = [countryCode]; + for (let i = 1; i < parts.length; i++) { + pathGids.push(parts.slice(0, i + 1).join('.') + version); + } + + // Create root node for the country (L0) + const rootName = s['NAME_0'] || countryCode; + const rootNode: GadmNode = { + name: rootName, + gid: countryCode, + level: 0, + hasChildren: true, + data: { GID_0: countryCode, NAME_0: rootName } + }; + + setRoots(prev => { + if (prev.find(p => p.gid === rootNode.gid)) return prev; + return [...prev, rootNode]; + }); + + // Trigger expand path with all GIDs up to the target + setTimeout(() => { + treeApiRef.current?.expandPath(pathGids); + }, 100); + + if (keepQuery !== undefined) { + setRegionQuery(keepQuery); + } else { + setRegionQuery(''); + } + setShowSuggestions(false); + }; + + const performLocate = async () => { + setIsLocating(true); + try { + const res = await fetch('http://ip-api.com/json/'); + if (res.ok) { + const data = await res.json(); + if (data.status === 'success' && data.lat && data.lon) { + const hierarchy = await fetchRegionHierarchy(data.lat, data.lon); + if (hierarchy && hierarchy.length > 0) { + const pathGids = hierarchy.map((d: any) => d.gid); + const l0 = hierarchy[0]; + const rootNode: GadmNode = { + name: l0.gadmName || l0.name || l0.gid, + gid: l0.gid, + level: l0.level, + hasChildren: l0.level < 5, + data: l0 + }; + + setRoots(prev => { + if (prev.find(p => p.gid === rootNode.gid)) return prev; + return [...prev, rootNode]; + }); + + setTimeout(() => { + treeApiRef.current?.expandPath(pathGids); + }, 100); + + const lastNode = hierarchy[hierarchy.length - 1]; + setRegionQuery(lastNode.gadmName || lastNode.name || lastNode.gid); + } + } + } + } catch (e) { + console.error('Failed to locate', e); + } finally { + setIsLocating(false); + } + }; + + useEffect(() => { + if (!hasAutoLocated) { + setHasAutoLocated(true); + performLocate(); + } + }, [hasAutoLocated]); + + useEffect(() => { + if (!regionQuery || regionQuery.length < 2) { setSuggestions([]); return; } + const id = setTimeout(async () => { + setLoadingSuggestions(true); + try { + const results = await searchGadmRegions(regionQuery); + setSuggestions(Array.isArray(results) ? results : (results as any).results || []); + if (document.activeElement === inputRef.current) { + setShowSuggestions(true); + } + } catch { setSuggestions([]); } + setLoadingSuggestions(false); + }, 400); + return () => clearTimeout(id); + }, [regionQuery]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (suggestionsWrapRef.current && !suggestionsWrapRef.current.contains(e.target as Node)) + setShowSuggestions(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const handleRemovePicked = (node: GadmNode) => { + const nextNodes = selectedNodes.filter(n => n.gid !== node.gid); + onSelectionChange(nextNodes); + treeApiRef.current?.setSelectedIds(nextNodes.map(n => n.gid)); + }; + + const handleClearAll = () => { + onSelectionChange([]); + treeApiRef.current?.setSelectedIds([]); + }; + + return ( +
+
+
+ +
+ { setRegionQuery(e.target.value); setShowSuggestions(true); }} + onFocus={() => setShowSuggestions(true)} + /> +
+ {loadingSuggestions ? ( + + ) : ( + + )} +
+ {showSuggestions && suggestions.length > 0 && ( +
    + {suggestions.map((s, i) => { + const nameKey = Object.keys(s).find(k => k.startsWith('NAME_')); + const gidKey = Object.keys(s).find(k => k.startsWith('GID_')); + const name = nameKey ? s[nameKey] : ''; + const gid = gidKey ? s[gidKey] : ''; + const displayName = s.hierarchy || s.name || name || gid; + return ( +
  • handleAddSuggestion(s)} + > + +
    +
    {displayName}
    +
    {gid}
    +
    +
  • + ); + })} +
+ )} +
+ +
+ {roots.length === 0 ? ( +
+ +

Search and select regions above to build your list

+
+ ) : ( +
+
+ n.gid)} + fetchChildren={async (node) => { + const children = await fetchRegionChildren(node.gid, node.level + 1); + const seen = new Set(); + return children.map((c: any) => { + const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown'; + return { + name: actualName, + gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(), + level: node.level + 1, + hasChildren: node.level + 1 < 5, + label: c.LABEL || undefined, + data: c + }; + }).filter((c: any) => { + if (seen.has(c.gid)) return false; + seen.add(c.gid); + const display = c.label || c.name; + if (display.toLowerCase() === 'unknown') return false; + return true; + }); + }} + onSelectionChange={onSelectionChange} + /> +
+ + {selectedNodes.length > 0 && ( +
+ onChangeResolution(node.gid, lvl)} + onClearAll={handleClearAll} + /> +
+ )} +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/gadm-picker/GadmTreePicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmTreePicker.tsx new file mode 100644 index 00000000..853a2836 --- /dev/null +++ b/packages/ui/src/modules/places/gadm-picker/GadmTreePicker.tsx @@ -0,0 +1,640 @@ +import React, { useMemo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { ChevronRight, Globe, MapPin, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface GadmNode { + name: string; + gid: string; + level: number; + hasChildren: boolean; + label?: string; + data?: any; +} + +export interface GadmTreePickerApi { + expandPath: (gids: string[]) => void; + setSelectedIds: (gids: string[]) => void; +} + +export interface GadmTreePickerProps { + data: GadmNode[]; + onSelect?: (node: GadmNode) => void; + onSelectionChange?: (nodes: GadmNode[]) => void; + onActivate?: (node: GadmNode) => void; + selectedId?: string; + className?: string; + fetchChildren?: (node: GadmNode) => Promise; + fontSize?: number; + apiRef?: React.MutableRefObject; + expandMapExternal?: Record; + setExpandMapExternal?: React.Dispatch>>; + initialSelectedIds?: string[]; +} + +type TreeRow = { + id: string; + name: string; + displayName: string; + node: GadmNode; + depth: number; + parentId: string | null; + expanded: boolean; + loading: boolean; +}; + +export interface ExpandState { + expanded: boolean; + children: GadmNode[]; + loading: boolean; +} + +function sortNodesAsc(nodes: GadmNode[]): GadmNode[] { + return [...nodes].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); +} + +function buildRows( + nodes: GadmNode[], + expandMap: Record, + depth: number = 0, + parentId: string | null = null +): TreeRow[] { + const rows: TreeRow[] = []; + for (const n of nodes) { + const state = expandMap[n.gid]; + const expanded = state?.expanded ?? false; + const loading = state?.loading ?? false; + // If we fetched children and got none, treat as leaf (no arrow) + const knownEmpty = state && !state.loading && state.children !== undefined && state.children.length === 0; + const effectiveNode = knownEmpty ? { ...n, hasChildren: false } : n; + rows.push({ + id: n.gid, + name: n.name, + displayName: n.label || n.name, + node: effectiveNode, + depth, + parentId, + expanded, + loading, + }); + if (n.hasChildren && expanded && state?.children?.length) { + const childRows = buildRows( + sortNodesAsc(state.children), + expandMap, + depth + 1, + n.gid + ); + rows.push(...childRows); + } + } + return rows; +} + +function findMatchIdx(rows: TreeRow[], str: string, startFrom: number, direction: 1 | -1 = 1): number { + const count = rows.length; + for (let i = 0; i < count; i++) { + const idx = ((startFrom + i * direction) % count + count) % count; + if (rows[idx].displayName.toLowerCase().startsWith(str)) return idx; + } + for (let i = 0; i < count; i++) { + const idx = ((startFrom + i * direction) % count + count) % count; + if (rows[idx].displayName.toLowerCase().includes(str)) return idx; + } + return -1; +} + +export const GadmTreePicker = React.forwardRef( + ({ data, onSelect, onSelectionChange, onActivate, selectedId, className, fetchChildren, fontSize = 14, apiRef, expandMapExternal, setExpandMapExternal, initialSelectedIds }, forwardedRef) => { + const [expandMapInternal, setExpandMapInternal] = useState>({}); + const expandMap = expandMapExternal ?? expandMapInternal; + const setExpandMap = setExpandMapExternal ?? setExpandMapInternal; + const rows = useMemo(() => buildRows(data, expandMap), [data, expandMap]); + const [focusIdx, setFocusIdx] = useState(0); + const [selectedIds, setSelectedIds] = useState>(() => new Set(initialSelectedIds ?? [])); + const anchorIdx = useRef(0); + const containerRef = useRef(null); + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + + const searchBuf = useRef(''); + const searchTimer = useRef(null); + const [searchDisplay, setSearchDisplay] = useState(''); + + const setRef = useCallback((el: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject).current = el; + if (typeof forwardedRef === 'function') forwardedRef(el); + else if (forwardedRef) (forwardedRef as React.MutableRefObject).current = el; + }, [forwardedRef]); + + const [pendingExpands, setPendingExpands] = useState([]); + + React.useImperativeHandle(apiRef, () => ({ + expandPath: (gids: string[]) => { + setPendingExpands(gids); + }, + setSelectedIds: (gids: string[]) => { + setSelectedIds(new Set(gids)); + } + }), []); + + + + useEffect(() => { + if (selectedId) { + setSelectedIds(prev => { + if (prev.size === 1 && prev.has(selectedId)) return prev; + return new Set([selectedId]); + }); + const idx = rows.findIndex(r => r.id === selectedId); + if (idx >= 0) { + setFocusIdx(idx); + } + } else if (selectedId === undefined && data.length === 0) { + setSelectedIds(new Set()); + } + }, [selectedId, rows, data.length]); + + const prevDataRef = useRef(data); + useLayoutEffect(() => { + if (data === prevDataRef.current && rows.length > 0) return; + prevDataRef.current = data; + + if (rows.length === 0) return; + + if (selectedId) { + const existingIdx = rows.findIndex(r => r.id === selectedId); + if (existingIdx >= 0) { + containerRef.current?.focus({ preventScroll: true }); + return; + } + } + + setFocusIdx(0); + anchorIdx.current = 0; + containerRef.current?.focus({ preventScroll: true }); + }, [data, rows, selectedId]); + + useEffect(() => { + rowRefs.current[focusIdx]?.scrollIntoView({ block: 'nearest' }); + }, [focusIdx]); + + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; + const rowsRef = useRef(rows); + rowsRef.current = rows; + + useEffect(() => { + if (!onSelectionChangeRef.current) return; + const currentRows = rowsRef.current; + const selectedNodes = currentRows.filter(r => selectedIds.has(r.id)).map(r => { + const node = r.node; + + // If it doesn't have parent GID_0 but is nested, walk up to construct the hierarchy + if (!node.data?.GID_0 && r.depth > 0) { + const syntheticData = { ...node.data }; + let curr: TreeRow | undefined = r; + while (curr) { + syntheticData[`GID_${curr.node.level}`] = curr.id; + syntheticData[`NAME_${curr.node.level}`] = curr.name; + const pId = curr.parentId; + curr = pId ? currentRows.find(or => or.id === pId) : undefined; + } + return { ...node, data: syntheticData }; + } + + // Always ensure GID of its own level is populated + if (!node.data[`GID_${node.level}`]) { + const syntheticData = { ...node.data }; + syntheticData[`GID_${node.level}`] = node.gid; + syntheticData[`NAME_${node.level}`] = node.name; + return { ...node, data: syntheticData }; + } + + return node; + }); + onSelectionChangeRef.current(selectedNodes); + }, [selectedIds]); + + const toggleExpand = useCallback(async (row: TreeRow) => { + if (!row.node.hasChildren || !fetchChildren) return; + const path = row.node.gid; + const current = expandMap[path]; + + if (current?.expanded) { + setExpandMap(prev => ({ + ...prev, + [path]: { ...prev[path], expanded: false }, + })); + } else if (current?.children?.length) { + setExpandMap(prev => ({ + ...prev, + [path]: { ...prev[path], expanded: true }, + })); + } else { + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: true, children: [], loading: true }, + })); + try { + const children = await fetchChildren(row.node); + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: true, children, loading: false }, + })); + } catch { + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: false, children: [], loading: false }, + })); + } + } + }, [expandMap, fetchChildren]); + + // Effect to process pending automatic expansions synchronously with the rendered rows + useEffect(() => { + if (pendingExpands.length > 0) { + const targetGid = pendingExpands[0]; + const row = rows.find(r => r.id === targetGid); + if (row) { + if (row.loading) return; // Wait for children to load + + if (!row.expanded && row.node.hasChildren && fetchChildren) { + toggleExpand(row); + } else if (row.expanded || !row.node.hasChildren) { + // Move to next pending target if it's already expanded or has no children + setPendingExpands(prev => prev.slice(1)); + } + } else { + const isAnyLoading = rows.some(r => r.loading); + if (!isAnyLoading) { + setPendingExpands(prev => prev.slice(1)); + } + } + } + }, [pendingExpands, rows, toggleExpand, fetchChildren]); + + const toggleExpandSelected = useCallback(async () => { + if (!fetchChildren || selectedIds.size === 0) return; + const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.node.hasChildren); + if (selectedDirs.length === 0) return; + + const anyCollapsed = selectedDirs.some(r => !r.expanded); + + if (!anyCollapsed) { + setExpandMap(prev => { + const next = { ...prev }; + for (const r of selectedDirs) { + next[r.node.gid] = { ...next[r.node.gid], expanded: false }; + } + return next; + }); + } else { + for (const r of selectedDirs) { + if (!r.expanded) { + await toggleExpand(r); + } + } + } + }, [fetchChildren, selectedIds, rows, toggleExpand]); + + const collapseNode = useCallback((row: TreeRow) => { + if (!row.node.hasChildren) return; + setExpandMap(prev => ({ + ...prev, + [row.node.gid]: { ...prev[row.node.gid], expanded: false }, + })); + }, []); + + const activate = useCallback((row: TreeRow) => { + onActivate?.(row.node); + }, [onActivate]); + + const selectRow = useCallback((idx: number) => { + setFocusIdx(idx); + anchorIdx.current = idx; + const row = rows[idx]; + if (!row) { + setSelectedIds(new Set()); + return; + } + setSelectedIds(new Set([row.id])); + onSelect?.(row.node); + }, [rows, onSelect]); + + const toggleSelectRow = useCallback((idx: number) => { + setFocusIdx(idx); + anchorIdx.current = idx; + const row = rows[idx]; + if (!row) return; + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }); + }, [rows]); + + const rangeSelectTo = useCallback((idx: number) => { + setFocusIdx(idx); + const start = Math.min(anchorIdx.current, idx); + const end = Math.max(anchorIdx.current, idx); + const ids = new Set(); + for (let i = start; i <= end; i++) { + const r = rows[i]; + if (r) ids.add(r.id); + } + setSelectedIds(ids); + }, [rows]); + + const handleClick = useCallback((idx: number, e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + toggleSelectRow(idx); + } else if (e.shiftKey) { + rangeSelectTo(idx); + } else { + selectRow(idx); + } + containerRef.current?.focus({ preventScroll: true }); + }, [selectRow, toggleSelectRow, rangeSelectTo]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.altKey) return; + + const count = rows.length; + if (count === 0) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + if (searchBuf.current) { + const found = findMatchIdx(rows, searchBuf.current, focusIdx + 1, 1); + if (found >= 0) selectRow(found); + } else { + const next = focusIdx < count - 1 ? focusIdx + 1 : 0; + if (e.shiftKey) rangeSelectTo(next); + else selectRow(next); + } + break; + } + case 'ArrowUp': { + e.preventDefault(); + if (searchBuf.current) { + const found = findMatchIdx(rows, searchBuf.current, focusIdx - 1 + count, -1); + if (found >= 0) selectRow(found); + } else { + const prev = focusIdx > 0 ? focusIdx - 1 : count - 1; + if (e.shiftKey) rangeSelectTo(prev); + else selectRow(prev); + } + break; + } + case 'ArrowRight': { + e.preventDefault(); + if (fetchChildren && selectedIds.size > 1) { + const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.node.hasChildren && !r.expanded); + if (selectedDirs.length > 0) { + for (const r of selectedDirs) toggleExpand(r); + break; + } + } + const rowR = rows[focusIdx]; + if (!rowR) break; + if (rowR.node.hasChildren && fetchChildren) { + if (!rowR.expanded) { + toggleExpand(rowR); + } else { + if (focusIdx < count - 1) selectRow(focusIdx + 1); + } + } else { + if (focusIdx < count - 1) selectRow(focusIdx + 1); + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + const visibleIds = new Set(rows.map(r => r.id)); + const cleanIds = new Set([...selectedIds].filter(id => visibleIds.has(id))); + if (cleanIds.size !== selectedIds.size) setSelectedIds(cleanIds); + + if (fetchChildren && cleanIds.size > 1) { + const selectedDirs = rows.filter(r => cleanIds.has(r.id) && r.node.hasChildren && r.expanded); + if (selectedDirs.length > 0) { + for (const r of selectedDirs) collapseNode(r); + break; + } + } + const rowL = rows[focusIdx]; + if (!rowL) break; + if (rowL.node.hasChildren && rowL.expanded && fetchChildren) { + collapseNode(rowL); + } else if (rowL.parentId) { + const parentIdx = rows.findIndex(r => r.id === rowL.parentId); + if (parentIdx >= 0) { + const parentRow = rows[parentIdx]; + if (parentRow) collapseNode(parentRow); + selectRow(parentIdx); + } + } + break; + } + case 'Enter': { + e.preventDefault(); + const row = rows[focusIdx]; + if (row) activate(row); + break; + } + case 'Backspace': { + e.preventDefault(); + if (searchBuf.current.length > 0) { + searchBuf.current = searchBuf.current.slice(0, -1); + if (searchBuf.current.length > 0) { + const found = findMatchIdx(rows, searchBuf.current, 0, 1); + if (found >= 0) selectRow(found); + } + } + break; + } + case 'Home': { + e.preventDefault(); + if (e.shiftKey) rangeSelectTo(0); + else selectRow(0); + break; + } + case 'End': { + e.preventDefault(); + if (e.shiftKey) rangeSelectTo(count - 1); + else selectRow(count - 1); + break; + } + case ' ': { + e.preventDefault(); + if (fetchChildren && selectedIds.size > 0) { + const hasSelectedDirs = rows.some(r => selectedIds.has(r.id) && r.node.hasChildren); + if (hasSelectedDirs) { + toggleExpandSelected(); + break; + } + } + const row = rows[focusIdx]; + if (row?.node.hasChildren && fetchChildren) { + toggleExpand(row); + } else if (row) { + activate(row); + } + break; + } + case 'a': { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const all = new Set(rows.map(r => r.id)); + setSelectedIds(all); + } + break; + } + case 'Escape': { + e.preventDefault(); + if (searchBuf.current) { + searchBuf.current = ''; + setSearchDisplay(''); + } else { + setSelectedIds(new Set()); + } + break; + } + default: { + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { + const char = e.key.toLowerCase(); + searchBuf.current += char; + setSearchDisplay(searchBuf.current); + + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => { searchBuf.current = ''; setSearchDisplay(''); }, 10000); + + const str = searchBuf.current; + const found = findMatchIdx(rows, str, focusIdx, 1); + if (found >= 0) selectRow(found); + } + break; + } + } + }, [focusIdx, rows, activate, selectRow, toggleExpand, collapseNode, fetchChildren, rangeSelectTo, selectedIds, toggleExpandSelected]); + + return ( +
+ {rows.map((row, idx) => { + const isSelected = selectedIds.has(row.id); + const isFocused = focusIdx === idx; + return ( +
{ rowRefs.current[idx] = el; }} + className={cn( + "flex items-center gap-1 py-0.5 cursor-pointer select-none rounded group", + isSelected && !isFocused && "bg-sky-100 dark:bg-sky-900/50 text-sky-900 dark:text-sky-100", + isFocused && "bg-sky-50 dark:bg-sky-800 text-sky-900 dark:text-sky-50 ring-1 ring-sky-400 dark:ring-sky-500", + !isSelected && !isFocused && "hover:bg-sky-50/50 dark:hover:bg-sky-900/40 text-slate-700 dark:text-slate-300", + )} + style={{ fontSize: fontSize, paddingLeft: `${row.depth * 16 + 4}px`, paddingRight: 8 }} + onClick={() => { + setFocusIdx(idx); + anchorIdx.current = idx; + containerRef.current?.focus({ preventScroll: true }); + }} + onDoubleClick={() => { + if (row.node.hasChildren && fetchChildren) { + toggleExpand(row); + } + activate(row); + }} + > + {row.node.hasChildren && fetchChildren ? ( + + ) : ( + + )} + +
+ {row.node.level === 0 ? ( + + ) : ( + + )} +
+ + + {(() => { + const label = row.displayName; + if (!isFocused || !searchDisplay) return label; + const lower = label.toLowerCase(); + const pos = lower.startsWith(searchDisplay) ? 0 : lower.indexOf(searchDisplay); + if (pos < 0) return label; + return ( + <> + {pos > 0 && label.slice(0, pos)} + + {label.slice(pos, pos + searchDisplay.length)} + + {label.slice(pos + searchDisplay.length)} + + ); + })()} + + + L{row.node.level} + + +
+ ); + })} +
+ ); + } +); + +GadmTreePicker.displayName = 'GadmTreePicker'; diff --git a/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx b/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx index 0e2df993..451742e4 100644 --- a/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx +++ b/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { MapPin, Loader2 } from 'lucide-react'; export const LEVEL_OPTIONS = [ @@ -45,11 +46,10 @@ export function GadmSearchControls({ key={opt.value} title={opt.label} onClick={() => setValue(opt.value)} - className={`px-2 py-1 text-xs font-medium rounded-sm transition-colors ${ - value === opt.value - ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 shadow-sm' - : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' - }`} + className={`px-2 py-1 text-xs font-medium rounded-sm transition-colors ${value === opt.value + ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 shadow-sm' + : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' + }`} > L{opt.value} @@ -58,10 +58,25 @@ export function GadmSearchControls({
); + const [levelsPortal, setLevelsPortal] = useState(null); + + useEffect(() => { + if (isHorizontal) { + setLevelsPortal(document.getElementById('gadm-controls-portal-levels')); + } + }, [isHorizontal]); + + const levelsContent = ( +
+ {renderSegments(levelOption, setLevelOption, 'Select Level : Country, State, Cities, ...')} + {renderSegments(resolutionOption, setResolutionOption, 'Resolution Level : Country, State, Cities, ...')} +
+ ); + return ( -
+
{/* Search Input */} -
+
{/* Level Selectors */} -
- {renderSegments(levelOption, setLevelOption, 'Search')} - {renderSegments(resolutionOption, setResolutionOption, 'Resolve')} -
+ {levelsPortal + ? createPortal(levelsContent, levelsPortal) + : levelsContent + }
); } diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 528a1ed7..e6fec1db 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -137,6 +137,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc {viewMode === 'map' && ( void }) { + return ( + + + + ); +} + +function GridSearchWizardInner({ onJobSubmitted }: { onJobSubmitted: (jobId: string) => void }) { const [step, setStep] = useState(1); // Step 1: Region - const [regionQuery, setRegionQuery] = useState(''); - const [suggestions, setSuggestions] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const [loadingSuggestions, setLoadingSuggestions] = useState(false); - - const [selectedGid, setSelectedGid] = useState(''); - const [selectedRegionName, setSelectedRegionName] = useState(''); + const [collectedNodes, setCollectedNodes] = useState([]); // Step 2: Query const [searchQuery, setSearchQuery] = useState(''); - // Step 3: Preview & Refine - const [previewResults, setPreviewResults] = useState([]); - const [loadingPreview, setLoadingPreview] = useState(false); - const [excludedTypes, setExcludedTypes] = useState([]); - const [showExcluded, setShowExcluded] = useState(false); + // Step 3: Preview + const [showSettings, setShowSettings] = useState(false); // Step 4: Submit - const [level, setLevel] = useState('cities'); + const [resolutions, setResolutions] = useState>({}); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); - const suggestionsWrapRef = useRef(null); - - useEffect(() => { - if (selectedGid) return; - if (!regionQuery || regionQuery.length < 2) { setSuggestions([]); return; } - const id = setTimeout(async () => { - setLoadingSuggestions(true); - try { - const results = await searchGadmRegions(regionQuery); - setSuggestions(Array.isArray(results) ? results : (results as any).results || []); - setShowSuggestions(true); - } catch { setSuggestions([]); } - setLoadingSuggestions(false); - }, 400); - return () => clearTimeout(id); - }, [regionQuery, selectedGid]); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (suggestionsWrapRef.current && !suggestionsWrapRef.current.contains(e.target as Node)) - setShowSuggestions(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); const handleNext = async () => { - if (step === 1 && !selectedGid) return; + if (step === 1 && collectedNodes.length === 0) return; if (step === 2 && !searchQuery.trim()) return; if (step === 1) { setStep(2); return; } - if (step === 2) { - setLoadingPreview(true); - setStep(3); - try { - const res = await previewGridSearch({ query: searchQuery }); - if (res.success) setPreviewResults(res.results); - else setPreviewResults([]); - } catch (err) { - console.error(err); - setPreviewResults([]); - } - setLoadingPreview(false); - return; - } + if (step === 2) { setStep(3); return; } if (step === 3) { setStep(4); return; } }; @@ -96,11 +53,46 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s setSubmitting(true); setError(''); try { + const regionNames = collectedNodes.map(n => n.name).join(' | '); + + const areas = collectedNodes.map(node => { + return { + gid: node.gid, + name: node.name, + level: resolutions[node.gid] ?? node.level, + raw: { + level: node.level, + gadmName: node.name, + gid: node.gid + } + }; + }); + + const guided = { + areas, + settings: { + gridMode: 'centers', + pathOrder: 'snake', + groupByRegion: true, + cellSize: 5, + cellOverlap: 0, + centroidOverlap: 0, + ghsFilterMode: 'OR', + maxCellsLimit: 50000, + maxElevation: 1000, + minDensity: 10, + minGhsPop: 26, + minGhsBuilt: 154, + allowMissingGhs: false, + bypassFilters: true, + } + }; + const res = await submitPlacesGridSearchJob({ - region: selectedRegionName, + region: regionNames, types: [searchQuery], - level, - enrichers: ['meta', 'emails'] + enrichers: ['meta', 'emails'], + guided }); addActiveGridSearchJob(res.jobId); onJobSubmitted(res.jobId); @@ -113,18 +105,13 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s const handleReset = () => { setStep(1); - setRegionQuery(''); - setSelectedGid(''); - setSelectedRegionName(''); + setCollectedNodes([]); setSearchQuery(''); - setPreviewResults([]); - setExcludedTypes([]); - setLevel('cities'); + setResolutions({}); setError(''); }; - const dummyUpdateExcluded = async (types: string[]) => setExcludedTypes(types); - const dummyEnrich = async (ids: string[], enrichers: string[]) => {}; + return (
@@ -145,58 +132,20 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s )}
-
+
{step === 1 && ( -
-

Where do you want to search?

-

Select a country, region, or city to perform the grid search.

- -
-
- -
- { setRegionQuery(e.target.value); setSelectedGid(''); setShowSuggestions(true); }} - onFocus={() => setShowSuggestions(true)} - /> - {loadingSuggestions && ( -
- )} - {showSuggestions && suggestions.length > 0 && ( -
    - {suggestions.map((s, i) => { - const nameKey = Object.keys(s).find(k => k.startsWith('NAME_')); - const gidKey = Object.keys(s).find(k => k.startsWith('GID_')); - const name = nameKey ? s[nameKey] : ''; - const gid = gidKey ? s[gidKey] : ''; - const displayName = s.hierarchy || s.name || name || gid; - return ( -
  • { - setRegionQuery(displayName); - setSelectedRegionName(displayName); - setSelectedGid(gid); - setShowSuggestions(false); - }} - > - -
    -
    {displayName}
    -
    {gid}
    -
    -
  • - ); - })} -
- )} +
+
+

Where do you want to search?

+

Select one or more countries, regions, or cities to perform the grid search.

+ setResolutions(prev => ({ ...prev, [gid]: lvl }))} + />
)} @@ -223,37 +172,20 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s )} {step === 3 && ( -
-

Preview Results

- - {loadingPreview ? ( -
- -

Running initial scan for {searchQuery}...

-
- ) : previewResults.length === 0 ? ( -
-

No results found for "{searchQuery}" in this region.

-
- ) : ( -
- {}} - showExcluded={showExcluded} - setShowExcluded={setShowExcluded} - enrich={dummyEnrich} - isEnriching={false} - enrichmentStatus={new Map()} - /> -
-

Showing top {previewResults.length} preview results. You can use the filters inside the table to exclude certain categories.

-
-
- )} +
+
+

Preview & Simulate

+
+
+ {}} + enrich={async () => {}} + isEnriching={false} + initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level }))} + /> +
)} @@ -266,35 +198,14 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s

Search Summary

- Region: - {selectedRegionName} + Regions: + {collectedNodes.map(n => n.name).join(', ')}
Query: "{searchQuery}"
-
- Excluded Types: - {excludedTypes.length > 0 ? excludedTypes.join(', ') : 'None'} -
-
- -
- - -

Choosing a finer granularity increases both accuracy and API cost.

{error && ( @@ -337,10 +248,10 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s {step < 4 ? ( ) : ( - - -
+ {canDebug && ( + - ))} + )}
+ + {canPlaybackSpeed && ( +
+ + {[0.5, 1, 5, 10, 50].map(s => ( + + ))} +
+ )}
); } diff --git a/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx b/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx index f6111c90..3803ddd3 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx +++ b/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx @@ -1,8 +1,9 @@ -import React, { useRef } from 'react'; -import { Copy, Download, Upload } from 'lucide-react'; +import React, { useRef, useState } from 'react'; +import { Copy, Download, Upload, Settings2, Sparkles } from 'lucide-react'; import { DeferredNumberInput, DeferredRangeSlider } from './DeferredInputs'; import { GridSimulatorSettings } from '../types'; import { T, translate } from '@/i18n'; +import { Slider } from '@/components/ui/slider'; interface SimulatorSettingsPanelProps { // Current State @@ -18,6 +19,26 @@ interface SimulatorSettingsPanelProps { export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) { const fileInputRef = useRef(null); + const [isAdvanced, setIsAdvanced] = useState(() => { + return !( + props.settings.gridMode === 'centers' && + props.settings.pathOrder === 'shortest' && + props.settings.bypassFilters === true && + props.settings.cellSize === 10 + ); + }); + + const handleModeChange = (advanced: boolean) => { + setIsAdvanced(advanced); + if (!advanced) { + props.applySettings({ + gridMode: 'centers', + pathOrder: 'shortest', + bypassFilters: true, + cellSize: 5 + }); + } + }; const handleCopySettings = () => { navigator.clipboard.writeText(JSON.stringify(props.getCurrentSettings(), null, 2)) @@ -56,10 +77,26 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) { return (
-
+

Settings

+
+ + +
-
-
- -
- - - - +
+ {isAdvanced && ( +
+ +
+ + + + +
-
+ )} -
- -
- - - - - + {isAdvanced && ( +
+ +
+ + + + + +
+ +
+ )} - - + {isAdvanced && (
-
+ )} +
-
-
-
- - props.applySettings({ cellSize: num })} - min={0.5} - step={0.5} - disabled={props.settings.gridMode === 'admin'} - /> -
+
+
+
+ + props.applySettings({ cellSize: val[0] })} + min={1} + max={1000} + step={1} + disabled={props.settings.gridMode === 'admin'} + /> +
+
+ {isAdvanced && ( +
{props.settings.gridMode === 'centers' ? (
@@ -297,18 +347,18 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) { />
)} +
+ + props.applySettings({ maxCellsLimit: num })} + step={500} + disabled={props.settings.gridMode === 'admin'} + /> +
-
- - props.applySettings({ maxCellsLimit: num })} - step={500} - disabled={props.settings.gridMode === 'admin'} - /> -
-
+ )}
); diff --git a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts index 6502adf6..2c2aea64 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts @@ -46,14 +46,14 @@ export function useGridSimulatorState({ // Config const defaultSettings: GridSimulatorSettings = { - gridMode: 'hex', - pathOrder: 'snake', + gridMode: 'centers', + pathOrder: 'shortest', groupByRegion: true, - cellSize: 2.5, + cellSize: 5, cellOverlap: 0, centroidOverlap: 50, ghsFilterMode: 'AND', - maxCellsLimit: 15000, + maxCellsLimit: 50000, maxElevation: 700, minDensity: 10, minGhsPop: 0, @@ -63,7 +63,7 @@ export function useGridSimulatorState({ enableGhsPop: false, enableGhsBuilt: false, allowMissingGhs: false, - bypassFilters: false + bypassFilters: true }; const [settings, setSettings] = useLocalStorage('pm_gridSettings', defaultSettings); diff --git a/packages/ui/src/modules/places/gridsearch/simulator/types.ts b/packages/ui/src/modules/places/gridsearch/simulator/types.ts index 55d7ca78..b700e5e5 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/types.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/types.ts @@ -18,4 +18,6 @@ export interface GridSearchSimulatorProps { setSimulatorPath: (data: FeatureCollection) => void; setSimulatorScanner: (data: FeatureCollection) => void; initialSettings?: Partial; + canDebug?: boolean; + canPlaybackSpeed?: boolean; }