report | stats

This commit is contained in:
lovebird 2026-03-26 16:49:40 +01:00
parent 4fc41f2eeb
commit 3b4e53be76
21 changed files with 1770 additions and 455 deletions

View File

@ -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<Point>, skipPolygons?: Feature<Polygon |
}
function sortGridCells(
validCells: SimulatorGridCell[],
pathOrder: GridGeneratorOptions['pathOrder'],
cellSize: number,
groupByRegion: boolean,
validCells: SimulatorGridCell[],
pathOrder: GridGeneratorOptions['pathOrder'],
cellSize: number,
groupByRegion: boolean,
featuresLength: number
): SimulatorGridCell[] {
let cellsToSort = [...validCells];
@ -282,7 +282,7 @@ async function generateAdminCells(
const props = f.properties || {};
const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000);
let { allowed, reason } = checkCellFilters(props, options, areaSqKm);
if (allowed && onFilterCell && !onFilterCell(f)) {
allowed = false;
@ -326,7 +326,6 @@ async function generateCenterCells(
const skippedCells: SimulatorGridCell[] = [];
const acceptedCenters: Feature<Point>[] = [];
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<boolean>
): Promise<GridGeneratorResult> {
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
);
}

View File

@ -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<MapPreset, MapFeatures> = {
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<MapFeatures>;
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<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings }) => {
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ 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<HTMLDivElement>(null);
const mapWrapper = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
@ -142,6 +196,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Selection and Sidebar State
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ competit
`}</style>
{/* Split View Container */}
<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">
<div ref={mapWrapper} className="flex-1 w-full h-full min-h-[400px] flex flex-col md:flex-row [&:fullscreen]:h-screen overflow-hidden relative bg-white dark:bg-gray-900">
{/* Left Tools Sidebar */}
{(gadmPickerActive || simulatorActive) && (
{(features.enableSidebarTools || sidebarOpen) && (
<>
<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"
className={`flex-shrink-0 flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 z-10 overflow-hidden shadow-xl ${(gadmPickerActive || simulatorActive || sidebarOpen) ? 'flex' : 'hidden'}`}
style={{ width: leftSidebarWidth }}
>
<Tabs
value={gadmPickerActive ? 'gadm' : 'simulator'}
value={simulatorActive ? 'simulator' : 'gadm'}
onValueChange={(val) => {
setGadmPickerActive(val === 'gadm');
setSimulatorActive(val === 'simulator');
@ -404,7 +519,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<TabsTrigger value="simulator" className="text-xs">Grid Search</TabsTrigger>
</TabsList>
<button
onClick={() => { setGadmPickerActive(false); setSimulatorActive(false); }}
onClick={() => { setGadmPickerActive(false); setSimulatorActive(false); setSidebarOpen(false); }}
className="ml-2 p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
title="Close Tools"
>
@ -415,7 +530,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<TabsContent value="gadm" forceMount className="data-[state=inactive]:hidden flex-1 overflow-y-auto m-0 p-4 border-none outline-none custom-scrollbar">
<GadmPicker
map={map.current}
active={gadmPickerActive}
active={gadmPickerActive || sidebarOpen || false}
onClose={() => setGadmPickerActive(false)}
onSelectionChange={(r, p) => {
setPickerRegions(r);
@ -435,6 +550,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
setSimulatorScanner={setSimulatorScanner}
onFilterCell={() => true}
initialSettings={initialSimulatorSettings}
canDebug={features.canDebug}
canPlaybackSpeed={features.canPlaybackSpeed}
/>
) : (
<div className="p-4 text-sm text-gray-500 text-center border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-900/50">
@ -446,7 +563,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
</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"
className={`w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize items-center justify-center z-30 transition-colors shrink-0 ${(gadmPickerActive || simulatorActive || sidebarOpen) ? 'flex' : 'hidden'}`}
onMouseDown={startLeftResizing}
>
<GripVertical className="w-3 h-3 text-gray-400" />
@ -456,53 +573,18 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Map Panel & Center Layout */}
<div className="relative h-full flex flex-col flex-1 transition-all duration-75 min-w-0 bg-white dark:bg-gray-900">
{/* Header: Search Region */}
<div className="absolute top-4 left-4 right-4 z-10 flex flex-col gap-2 pointer-events-none items-start">
<div className="flex items-center gap-2 w-full max-w-full">
{/* Tools Button */}
<div className="flex items-center gap-1.5 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 pointer-events-auto shrink-0">
<button
onClick={() => {
if (gadmPickerActive || simulatorActive) {
setGadmPickerActive(false);
setSimulatorActive(false);
} else {
setGadmPickerActive(true);
}
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${
(gadmPickerActive || simulatorActive)
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 border-gray-200 dark:border-gray-700'
}`}
title="Toggle Tools Sidebar"
>
{(gadmPickerActive || simulatorActive) ? <X className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}
Tools
</button>
{/* Info Button */}
<button
onClick={() => setInfoPanelOpen(!infoPanelOpen)}
className={`p-2 rounded-md transition-colors ${infoPanelOpen
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title="Show Information"
>
<Info className="w-5 h-5" />
</button>
</div>
<div id="gadm-controls-portal" className="flex items-center justify-start pointer-events-auto empty:hidden bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1.5 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50"></div>
<div className="flex-1"></div> { /* Spacer */}
</div>
<div className="flex items-center w-full max-w-full">
<div id="sim-controls-portal" className="flex items-center justify-start pointer-events-auto empty:hidden bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1.5 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50"></div>
</div>
</div>
{/* Header: Search Region & Tools Overlay */}
<MapOverlayToolbars
features={features}
sidebarOpen={sidebarOpen}
gadmPickerActive={gadmPickerActive}
simulatorActive={simulatorActive}
setSidebarOpen={setSidebarOpen}
setGadmPickerActive={setGadmPickerActive}
setSimulatorActive={setSimulatorActive}
infoPanelOpen={infoPanelOpen}
setInfoPanelOpen={setInfoPanelOpen}
/>
{/* Map Viewport */}
<div className="relative flex-1 min-h-0 w-full overflow-hidden">
@ -521,11 +603,27 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ 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 && (
<div className="sr-only">
<GridSearchSimulator
pickerRegions={pickerRegions}
pickerPolygons={pickerPolygons}
setSimulatorData={setSimulatorData}
setSimulatorPath={setSimulatorPath}
setSimulatorScanner={setSimulatorScanner}
onFilterCell={() => true}
initialSettings={{ gridMode: 'centers', cellSize: 50 }}
/>
</div>
)}
</div>
{/* Footer: Status Info & Toggles */}
@ -535,33 +633,57 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ 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}
>
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={setShowDensity}
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
<RulerButton map={map.current} />
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title="Enrich Visible Locations"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
{features.enableLayerToggles && (
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={setShowDensity}
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
)}
{features.enableRuler && <RulerButton map={map.current} />}
{features.enableEnrichment && (
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title="Enrich Visible Locations"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
)}
{enrichmentProgress && (
{features.enableEnrichment && enrichmentProgress && (
<div className="absolute bottom-10 right-4 bg-white dark:bg-gray-800 p-2 rounded shadow text-xs border border-gray-200 dark:border-gray-700 flex items-center gap-2 z-50">
<Loader2 className="w-3 h-3 animate-spin text-indigo-500" />
<span>{enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total})</span>
@ -571,7 +693,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
</div>
{/* Resizable Handle and Property Pane */}
{(selectedLocation || infoPanelOpen) && (
{((features.enableLocationDetails && selectedLocation) || (features.enableInfoPanel && infoPanelOpen)) && (
<>
{/* Drag Handle */}
<div
@ -583,12 +705,12 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Property Pane */}
<div className="hidden lg:block h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative" style={{ width: sidebarWidth }}>
{selectedLocation ? (
{features.enableLocationDetails && selectedLocation ? (
<LocationDetailView
competitor={selectedLocation as unknown as CompetitorFull}
onClose={() => setSelectedLocation(null)}
/>
) : (
) : features.enableInfoPanel && infoPanelOpen ? (
<InfoPanel
isOpen={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
@ -596,7 +718,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
lng={mapInternals?.lng}
locationName={currentCenterLabel}
/>
)}
) : null}
</div>
</>
)}

View File

@ -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 {

View File

@ -280,3 +280,31 @@ export async function resolveGadmHierarchy(lat: number, lng: number): Promise<Ga
}
return rows;
}
export const fetchRegionChildren = async (adminGid: string, targetLevel: number): Promise<any[]> => {
// 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<string, string> = {
'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 || [];
});
};

View File

@ -28,8 +28,8 @@ export function MapFooter({
children
}: MapFooterProps) {
return (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20">
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400">
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20 shrink-0 w-full overflow-hidden">
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
{currentCenterLabel ? (
<div className="font-medium text-gray-900 dark:text-gray-200 flex items-center gap-1">
<MapIcon className="w-3 h-3 text-indigo-500" />

View File

@ -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 (
<div className="absolute top-4 left-4 right-4 z-10 flex flex-col gap-2 pointer-events-none items-start">
<div className="flex flex-wrap items-start gap-2 w-full max-w-full">
{/* Tools & Info Buttons */}
{(features.enableSidebarTools || features.showSidebar || features.enableInfoPanel) && (
<div className="flex items-center gap-1.5 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 pointer-events-auto shrink-0">
{/* Sidebar Toggle Button */}
{(features.enableSidebarTools || features.showSidebar) && (
<button
onClick={() => {
if (isExpanded) {
setGadmPickerActive(false);
setSimulatorActive(false);
setSidebarOpen(false);
} else {
setSidebarOpen(true);
}
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${
isExpanded
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 border-gray-200 dark:border-gray-700'
}`}
title="Toggle Sidebar"
>
<LayoutGrid className="w-4 h-4" />
{isExpanded ? 'Hide Tools' : 'Show Tools'}
</button>
)}
{/* Info Button */}
{features.enableInfoPanel && (
<button
onClick={() => setInfoPanelOpen(!infoPanelOpen)}
className={`p-2 rounded-md transition-colors ${infoPanelOpen
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title="Show Information"
>
<Info className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Portal target for Simulator Controls */}
<div
id="sim-controls-portal"
className="flex max-w-full items-center justify-start pointer-events-auto empty:hidden bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 shrink-0"
></div>
<div className="flex-1 min-w-0"></div> { /* Spacer */}
</div>
</div>
);
}

View File

@ -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);
}

View File

@ -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<string, number>;
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 (
<div className="flex flex-col gap-1.5 h-full">
<div className="flex justify-between items-center px-2 py-1.5 shrink-0">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Selected ({selectedNodes.length})
</span>
{onClearAll && (
<button onClick={onClearAll} className="text-gray-400 hover:text-red-500 transition-colors" title="Clear all">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex flex-col gap-1">
{selectedNodes.map((node) => {
const crumbs = buildBreadcrumbs(node);
const currentRes = resolutions[node.gid] ?? node.level;
return (
<div
key={node.gid}
className="flex items-center gap-1.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200/60 dark:border-amber-800/40 rounded-lg px-2.5 py-1.5 group"
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
{/* Breadcrumbs */}
<div className="flex items-center gap-0.5 min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-300 truncate">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <ChevronRight className="w-2.5 h-2.5 text-amber-400/60 shrink-0" />}
<span className={i === crumbs.length - 1 ? 'font-semibold truncate' : 'opacity-70 shrink-0'}>
{c}
</span>
</React.Fragment>
))}
</div>
{/* Compact resolution pills */}
<div className="flex gap-px shrink-0">
{[0, 1, 2, 3, 4, 5].map((l) => (
<button
key={l}
onClick={() => onChangeResolution(node, l)}
className={`w-5 h-5 rounded flex items-center justify-center text-[9px] font-bold transition-all ${
currentRes === l
? 'bg-amber-500 text-white shadow-sm'
: 'text-amber-600/50 dark:text-amber-400/40 hover:bg-amber-200/60 dark:hover:bg-amber-800/40'
}`}
title={`Level ${l}`}
>
{l}
</button>
))}
</div>
<button
onClick={(e) => { e.stopPropagation(); onRemove(node); }}
className="text-amber-400/60 hover:text-red-500 transition-colors shrink-0 ml-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@ -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 ? (
<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">
<div className="flex items-baseline flex-1 min-w-0 mr-2">
<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 group/row">
<div
className="flex items-baseline flex-1 min-w-0 mr-2 cursor-pointer group-hover/row:text-sky-600 transition-colors"
onClick={() => handlePreviewRegion(row.gid, row.gadmName, row.level)}
>
{loadingHighlightGid === row.gid && <Loader2 className="w-3 h-3 animate-spin text-sky-500 mr-1.5 self-center shrink-0" />}
<span className="text-sm font-medium text-sky-900 dark:text-sky-100 truncate flex-shrink">
<span className="text-sm font-medium text-sky-900 dark:text-sky-100 group-hover/row:text-sky-600 group-hover/row:underline dark:group-hover/row:text-sky-300 truncate flex-shrink">
{row.gadmName}
</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>

View File

@ -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<React.SetStateAction<GadmNode[]>>;
expandMap: Record<string, ExpandState>;
setExpandMap: React.Dispatch<React.SetStateAction<Record<string, ExpandState>>>;
regionQuery: string;
setRegionQuery: React.Dispatch<React.SetStateAction<string>>;
hasAutoLocated: boolean;
setHasAutoLocated: React.Dispatch<React.SetStateAction<boolean>>;
treeApiRef: React.MutableRefObject<GadmTreePickerApi | null>;
}
const GadmPickerContext = createContext<GadmPickerState | null>(null);
export function GadmPickerProvider({ children }: { children: React.ReactNode }) {
const [roots, setRoots] = useState<GadmNode[]>([]);
const [expandMap, setExpandMap] = useState<Record<string, ExpandState>>({});
const [regionQuery, setRegionQuery] = useState('');
const [hasAutoLocated, setHasAutoLocated] = useState(false);
const treeApiRef = useRef<GadmTreePickerApi | null>(null);
return (
<GadmPickerContext.Provider value={{
roots, setRoots,
expandMap, setExpandMap,
regionQuery, setRegionQuery,
hasAutoLocated, setHasAutoLocated,
treeApiRef,
}}>
{children}
</GadmPickerContext.Provider>
);
}
export function useGadmPicker(): GadmPickerState {
const ctx = useContext(GadmPickerContext);
if (!ctx) throw new Error('useGadmPicker must be used within GadmPickerProvider');
return ctx;
}

View File

@ -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<string, number>;
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<any[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const suggestionsWrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col gap-4 flex-1 min-h-0 w-full">
<div className="relative mt-2" ref={suggestionsWrapRef}>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MapPin className="h-6 w-6 text-gray-400" />
</div>
<input
ref={inputRef}
type="text"
placeholder="Search countries, regions, cities to add..."
className="w-full block pl-12 pr-12 py-4 text-lg border-2 border-transparent bg-gray-100 dark:bg-gray-900 focus:bg-white dark:focus:bg-gray-800 focus:border-indigo-500 focus:ring-0 rounded-xl transition-colors dark:text-white"
value={regionQuery}
onChange={e => { setRegionQuery(e.target.value); setShowSuggestions(true); }}
onFocus={() => setShowSuggestions(true)}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
{loadingSuggestions ? (
<Loader2 className="w-6 h-6 animate-spin text-indigo-500" />
) : (
<button
type="button"
onClick={performLocate}
disabled={isLocating}
className="p-1.5 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors disabled:opacity-50"
title="Use my current location"
>
{isLocating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Locate className="w-5 h-5" />}
</button>
)}
</div>
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute z-50 w-full mt-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-2xl max-h-64 overflow-auto py-2">
{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 (
<li
key={i}
className="px-6 py-3 cursor-pointer hover:bg-indigo-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
onClick={() => handleAddSuggestion(s)}
>
<MapPin className="h-4 w-4 text-gray-400" />
<div>
<div className="font-medium text-gray-800 dark:text-gray-200">{displayName}</div>
<div className="text-sm text-gray-500">{gid}</div>
</div>
</li>
);
})}
</ul>
)}
</div>
<div className="flex-1 min-h-0 border rounded-xl overflow-hidden bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 flex flex-col">
{roots.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400">
<MapPin className="w-12 h-12 mb-2 opacity-50" />
<p>Search and select regions above to build your list</p>
</div>
) : (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-1 min-h-0 border-b border-gray-200 dark:border-gray-700">
<GadmTreePicker
apiRef={treeApiRef}
data={roots}
expandMapExternal={expandMap}
setExpandMapExternal={setExpandMap}
initialSelectedIds={selectedNodes.map(n => 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}
/>
</div>
{selectedNodes.length > 0 && (
<div className="shrink-0 max-h-[50%] overflow-y-auto p-4 bg-gray-50/30 dark:bg-gray-800/10">
<GadmPickedRegions
selectedNodes={selectedNodes}
resolutions={resolutions}
onRemove={handleRemovePicked}
onChangeResolution={(node, lvl) => onChangeResolution(node.gid, lvl)}
onClearAll={handleClearAll}
/>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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<GadmNode[]>;
fontSize?: number;
apiRef?: React.MutableRefObject<GadmTreePickerApi | null>;
expandMapExternal?: Record<string, ExpandState>;
setExpandMapExternal?: React.Dispatch<React.SetStateAction<Record<string, ExpandState>>>;
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<string, ExpandState>,
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<HTMLDivElement, GadmTreePickerProps>(
({ data, onSelect, onSelectionChange, onActivate, selectedId, className, fetchChildren, fontSize = 14, apiRef, expandMapExternal, setExpandMapExternal, initialSelectedIds }, forwardedRef) => {
const [expandMapInternal, setExpandMapInternal] = useState<Record<string, ExpandState>>({});
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<Set<string>>(() => new Set(initialSelectedIds ?? []));
const anchorIdx = useRef<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const searchBuf = useRef('');
const searchTimer = useRef<any>(null);
const [searchDisplay, setSearchDisplay] = useState('');
const setRef = useCallback((el: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (typeof forwardedRef === 'function') forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}, [forwardedRef]);
const [pendingExpands, setPendingExpands] = useState<string[]>([]);
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<string>();
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 (
<div
ref={setRef}
data-testid="gadm-tree"
className={cn("w-full h-full min-h-0 overflow-y-auto outline-none fb-tree-container p-1", className)}
tabIndex={0}
onKeyDown={handleKeyDown}
>
{rows.map((row, idx) => {
const isSelected = selectedIds.has(row.id);
const isFocused = focusIdx === idx;
return (
<div
key={`${row.id}-${idx}`}
data-testid="gadm-tree-node"
data-node-id={row.id}
ref={(el) => { 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 ? (
<button
type="button"
className="flex items-center justify-center w-4 h-4 shrink-0 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpand(row);
}}
tabIndex={-1}
>
{row.loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<ChevronRight
size={14}
className={cn(
"transition-transform duration-150",
row.expanded && "rotate-90"
)}
/>
)}
</button>
) : (
<span className="w-4 shrink-0" />
)}
<div className="flex items-center justify-center text-slate-400 dark:text-slate-500 shrink-0">
{row.node.level === 0 ? (
<Globe size={16} className={cn(row.expanded && "text-sky-500")} />
) : (
<MapPin size={16} className={cn(row.expanded && "text-sky-500")} />
)}
</div>
<span className="truncate flex-1 ml-1">
{(() => {
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)}
<span className="bg-amber-200/80 text-amber-900 border-b border-amber-500 dark:bg-sky-500/30 dark:text-sky-200 dark:border-sky-400">
{label.slice(pos, pos + searchDisplay.length)}
</span>
{label.slice(pos + searchDisplay.length)}
</>
);
})()}
</span>
<span className="text-[10px] text-slate-400 ml-1.5 opacity-70 shrink-0">
L{row.node.level}
</span>
<button
type="button"
tabIndex={-1}
className="ml-1.5 shrink-0 relative inline-flex items-center"
onClick={(e) => {
e.stopPropagation();
toggleSelectRow(idx);
containerRef.current?.focus({ preventScroll: true });
}}
>
<span className={cn(
"w-7 h-4 rounded-full transition-colors duration-200",
isSelected
? "bg-indigo-500"
: "bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500"
)} />
<span className={cn(
"absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform duration-200",
isSelected && "translate-x-3"
)} />
</button>
</div>
);
})}
</div>
);
}
);
GadmTreePicker.displayName = 'GadmTreePicker';

View File

@ -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}
</button>
@ -58,10 +58,25 @@ export function GadmSearchControls({
</div>
);
const [levelsPortal, setLevelsPortal] = useState<HTMLElement | null>(null);
useEffect(() => {
if (isHorizontal) {
setLevelsPortal(document.getElementById('gadm-controls-portal-levels'));
}
}, [isHorizontal]);
const levelsContent = (
<div className={`flex ${isHorizontal ? 'flex-row items-center justify-center gap-6 px-4 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1.5 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 w-[500px]' : 'flex-col gap-3'}`}>
{renderSegments(levelOption, setLevelOption, 'Select Level : Country, State, Cities, ...')}
{renderSegments(resolutionOption, setResolutionOption, 'Resolution Level : Country, State, Cities, ...')}
</div>
);
return (
<div className={`flex ${isHorizontal ? 'flex-row items-center gap-4' : 'flex-col gap-4'} flex-shrink-0`}>
<div className={`flex ${isHorizontal ? 'flex-col items-start gap-2 w-[500px]' : 'flex-col gap-4'} flex-shrink-0`}>
{/* Search Input */}
<div className={`relative z-20 ${isHorizontal ? 'w-64' : ''}`} ref={suggestionsWrapRef}>
<div className={`relative z-20 w-full ${isHorizontal ? 'bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-1.5 rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50' : ''}`} ref={suggestionsWrapRef}>
<div className="relative">
<MapPin className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<input
@ -98,10 +113,10 @@ export function GadmSearchControls({
</div>
{/* Level Selectors */}
<div className={`flex ${isHorizontal ? 'flex-row items-center gap-4' : 'flex-col gap-3'}`}>
{renderSegments(levelOption, setLevelOption, 'Search')}
{renderSegments(resolutionOption, setResolutionOption, 'Resolve')}
</div>
{levelsPortal
? createPortal(levelsContent, levelsPortal)
: levelsContent
}
</div>
);
}

View File

@ -137,6 +137,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
{viewMode === 'map' && (
<CompetitorsMapView
preset="SearchView"
competitors={competitors}
onMapCenterUpdate={handleMapCenterUpdate}
enrich={dummyEnrich}

View File

@ -1,88 +1,45 @@
import React, { useState, useEffect, useRef } from 'react';
import { Loader2, Search, MapPin, CheckCircle, ChevronRight, ChevronLeft } from 'lucide-react';
import { previewGridSearch, submitPlacesGridSearchJob, addActiveGridSearchJob } from '../client-gridsearch';
import { searchGadmRegions } from '../client-searches';
import { CompetitorsGridView } from '../CompetitorsGridView';
import { Loader2, Search, MapPin, CheckCircle, ChevronRight, ChevronLeft, Settings } from 'lucide-react';
import { submitPlacesGridSearchJob, addActiveGridSearchJob } from '../client-gridsearch';
import { CompetitorsMapView } from '../CompetitorsMapView';
import { GadmRegionCollector } from '../gadm-picker/GadmRegionCollector';
import { GadmNode } from '../gadm-picker/GadmTreePicker';
import { GadmPickerProvider } from '../gadm-picker/GadmPickerContext';
const MOCK_SETTINGS = {
known_types: [],
excluded_types: [],
email_mode: 'fast' as const,
auto_enrich: false
};
export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: string) => void }) {
return (
<GadmPickerProvider>
<GridSearchWizardInner onJobSubmitted={onJobSubmitted} />
</GadmPickerProvider>
);
}
function GridSearchWizardInner({ onJobSubmitted }: { onJobSubmitted: (jobId: string) => void }) {
const [step, setStep] = useState(1);
// Step 1: Region
const [regionQuery, setRegionQuery] = useState('');
const [suggestions, setSuggestions] = useState<any[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const [selectedGid, setSelectedGid] = useState('');
const [selectedRegionName, setSelectedRegionName] = useState('');
const [collectedNodes, setCollectedNodes] = useState<GadmNode[]>([]);
// Step 2: Query
const [searchQuery, setSearchQuery] = useState('');
// Step 3: Preview & Refine
const [previewResults, setPreviewResults] = useState<any[]>([]);
const [loadingPreview, setLoadingPreview] = useState(false);
const [excludedTypes, setExcludedTypes] = useState<string[]>([]);
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<Record<string, number>>({});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const suggestionsWrapRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col items-center justify-center p-6 h-full border-box w-full">
@ -145,58 +132,20 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s
)}
<div className={`w-full flex-grow flex flex-col ${step === 3 ? 'max-w-6xl' : 'max-w-xl'} bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden transition-all duration-300`}>
<div className="p-8 flex-grow overflow-y-auto">
<div className="p-8 flex-grow overflow-y-auto flex flex-col min-h-0">
{step === 1 && (
<div className="space-y-6">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2">Where do you want to search?</h2>
<p className="text-gray-500 dark:text-gray-400">Select a country, region, or city to perform the grid search.</p>
<div className="relative mt-8" ref={suggestionsWrapRef}>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MapPin className="h-6 w-6 text-gray-400" />
</div>
<input
type="text"
placeholder="e.g., Berlin, Germany"
className="w-full block pl-12 pr-4 py-4 text-lg border-2 border-transparent bg-gray-100 dark:bg-gray-900 focus:bg-white dark:focus:bg-gray-800 focus:border-indigo-500 focus:ring-0 rounded-xl transition-colors dark:text-white"
value={regionQuery}
onChange={e => { setRegionQuery(e.target.value); setSelectedGid(''); setShowSuggestions(true); }}
onFocus={() => setShowSuggestions(true)}
/>
{loadingSuggestions && (
<div className="absolute right-4 top-4"><Loader2 className="w-6 h-6 animate-spin text-indigo-500" /></div>
)}
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute z-50 w-full mt-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-2xl max-h-64 overflow-auto py-2">
{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 (
<li
key={i}
className="px-6 py-3 cursor-pointer hover:bg-indigo-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
onClick={() => {
setRegionQuery(displayName);
setSelectedRegionName(displayName);
setSelectedGid(gid);
setShowSuggestions(false);
}}
>
<MapPin className="h-4 w-4 text-gray-400" />
<div>
<div className="font-medium text-gray-800 dark:text-gray-200">{displayName}</div>
<div className="text-sm text-gray-500">{gid}</div>
</div>
</li>
);
})}
</ul>
)}
<div className="flex flex-col flex-1 min-h-0 space-y-6">
<div className="shrink-0">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2">Where do you want to search?</h2>
<p className="text-gray-500 dark:text-gray-400">Select one or more countries, regions, or cities to perform the grid search.</p>
</div>
<GadmRegionCollector
selectedNodes={collectedNodes}
onSelectionChange={setCollectedNodes}
resolutions={resolutions}
onChangeResolution={(gid, lvl) => setResolutions(prev => ({ ...prev, [gid]: lvl }))}
/>
</div>
)}
@ -223,37 +172,20 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s
)}
{step === 3 && (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 border-b pb-4 dark:border-gray-700">Preview Results</h2>
{loadingPreview ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mb-4" />
<p className="text-gray-500">Running initial scan for {searchQuery}...</p>
</div>
) : previewResults.length === 0 ? (
<div className="py-20 text-center">
<p className="text-gray-500 text-lg">No results found for "{searchQuery}" in this region.</p>
</div>
) : (
<div className="border rounded-xl bg-gray-50 dark:bg-gray-900/50 p-1">
<CompetitorsGridView
competitors={previewResults}
loading={false}
settings={{ ...MOCK_SETTINGS, excluded_types: excludedTypes }}
updateExcludedTypes={dummyUpdateExcluded}
onOpenSettings={() => {}}
showExcluded={showExcluded}
setShowExcluded={setShowExcluded}
enrich={dummyEnrich}
isEnriching={false}
enrichmentStatus={new Map()}
/>
<div className="mt-4 px-4 pb-2">
<p className="text-sm text-gray-500">Showing top {previewResults.length} preview results. You can use the filters inside the table to exclude certain categories.</p>
</div>
</div>
)}
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between border-b pb-3 dark:border-gray-700 shrink-0">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">Preview & Simulate</h2>
</div>
<div className="border rounded-xl overflow-hidden flex-1 min-h-0 mt-3 flex flex-col">
<CompetitorsMapView
preset="Minimal"
competitors={[]}
onMapCenterUpdate={() => {}}
enrich={async () => {}}
isEnriching={false}
initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level }))}
/>
</div>
</div>
)}
@ -266,35 +198,14 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">Search Summary</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Region:</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedRegionName}</span>
<span className="text-gray-600 dark:text-gray-400">Regions:</span>
<span className="font-medium text-gray-900 dark:text-gray-100 text-right max-w-xs">{collectedNodes.map(n => n.name).join(', ')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Query:</span>
<span className="font-medium text-gray-900 dark:text-gray-100">"{searchQuery}"</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Excluded Types:</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{excludedTypes.length > 0 ? excludedTypes.join(', ') : 'None'}</span>
</div>
</div>
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Grid Granularity Level</label>
<select
className="w-full block py-3 px-4 text-base border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:border-indigo-500 focus:ring-indigo-500 rounded-xl"
value={level}
onChange={e => setLevel(e.target.value)}
>
<option value="states">States (Level 1)</option>
<option value="provinces">Provinces (Level 2)</option>
<option value="districts">Districts (Level 3)</option>
<option value="cities">Cities / Communes (Level 4)</option>
<option value="towns">Towns (Level 5)</option>
<option value="villages">Villages (Level 6)</option>
</select>
<p className="mt-2 text-xs text-gray-500">Choosing a finer granularity increases both accuracy and API cost.</p>
</div>
{error && (
@ -337,10 +248,10 @@ export function GridSearchWizard({ onJobSubmitted }: { onJobSubmitted: (jobId: s
{step < 4 ? (
<button
onClick={handleNext}
disabled={(step === 1 && !selectedGid) || (step === 2 && !searchQuery.trim()) || loadingPreview}
disabled={(step === 1 && collectedNodes.length === 0) || (step === 2 && !searchQuery.trim())}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{step === 2 && loadingPreview ? 'Scanning...' : 'Continue'} <ChevronRight className="w-5 h-5 ml-1" />
Continue <ChevronRight className="w-5 h-5 ml-1" />
</button>
) : (
<button

View File

@ -29,8 +29,6 @@ export function JobViewer({ jobId }: { jobId: string }) {
setLoading(true);
setError(null);
const data = await fetchPlacesGridSearchById(jobId);
console.log(`job results`, data);
if (!active) return;
// Handle nested wrapper if present (sometimes { data: { ... } } or just the object)
@ -92,13 +90,6 @@ export function JobViewer({ jobId }: { jobId: string }) {
const allResults = jobData?.result?.searchResult?.results || competitors;
const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[];
console.log(`foundTypes`, foundTypes);
console.log(`allResults`, allResults);
console.log(`jobData`, jobData);
console.log('guidedAreas', guidedAreas);
console.log('searchSettings', searchSettings);
console.log('searchQuery', searchQuery);
return (
<div className="flex flex-col p-6 flex-1 w-full box-border overflow-hidden min-h-0">
<div className="w-full flex-1 min-h-0 flex flex-col bg-white dark:bg-gray-800 rounded-2xl shadow-xl transition-all duration-300">

View File

@ -13,7 +13,15 @@ export function GridSearchSimulator(props: GridSearchSimulatorProps) {
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
useEffect(() => {
setPortalTarget(document.getElementById('sim-controls-portal'));
const el = document.getElementById('sim-controls-portal');
if (el) { setPortalTarget(el); return; }
// Retry until target is available
const interval = setInterval(() => {
const el = document.getElementById('sim-controls-portal');
if (el) { setPortalTarget(el); clearInterval(interval); }
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 3000);
return () => { clearInterval(interval); clearTimeout(timeout); };
}, []);
const hasRegions = props.pickerRegions && props.pickerRegions.length > 0;
@ -50,6 +58,8 @@ export function GridSearchSimulator(props: GridSearchSimulatorProps) {
setSpeed={state.setSpeed}
computeGrid={state.computeGrid}
handleClear={state.handleClear}
canDebug={props.canDebug}
canPlaybackSpeed={props.canPlaybackSpeed}
/>,
portalTarget
)}

View File

@ -15,11 +15,14 @@ interface SimulatorControlsProps {
setSpeed: (s: number) => void;
computeGrid: (autoplay: boolean | 'preview') => void;
handleClear: () => void;
canDebug?: boolean;
canPlaybackSpeed?: boolean;
}
export function SimulatorControls({
gridCells, progressIndex, isPlaying, speed, isCalculating, pickerPolygons, skippedCellsRef,
setIsPlaying, setProgressIndex, setSpeed, computeGrid, handleClear
setIsPlaying, setProgressIndex, setSpeed, computeGrid, handleClear,
canDebug = true, canPlaybackSpeed = true
}: SimulatorControlsProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-2">
@ -48,55 +51,44 @@ export function SimulatorControls({
>
<Square className="w-4 h-4" />
</button>
<button
onClick={() => {
setIsPlaying(false);
if (gridCells.length === 0) {
computeGrid('preview');
} else {
setProgressIndex(gridCells.length);
}
}}
disabled={isCalculating || !pickerPolygons || pickerPolygons.length === 0}
className="p-2 rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title={translate("Preview all cells")}
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1"><T>Preview</T></span>
</button>
<button
onClick={() => {
console.log('--- GRID SEARCH DEBUG DUMP ---');
console.log(`Valid Cells: ${gridCells.length}`, gridCells.slice(0, 5).map(c => c.properties));
const skipped = skippedCellsRef.current;
console.log(`Skipped Cells: ${skipped.length}`);
const skippedByReason = skipped.reduce((acc, cell) => {
const r = cell.properties?._reason || cell.properties?.sim_skip_reason || 'Unknown';
acc[r] = (acc[r] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Skipped Breakdown:', skippedByReason);
console.log('Skipped Sample (first 5):', skipped.slice(0, 5).map(c => c.properties));
console.log('------------------------------');
}}
disabled={isCalculating}
className="p-2 rounded bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title={translate("Print Debug Info to Console")}
>
<Bug className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1"><T>Debug</T></span>
</button>
</div>
{canDebug && (
<button
onClick={() => {
console.log('--- GRID SEARCH DEBUG DUMP ---');
console.log(`Valid Cells: ${gridCells.length}`, gridCells.slice(0, 5).map(c => c.properties));
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded p-1 text-xs">
<FastForward className="w-3 h-3 text-gray-500 mr-1" />
{[0.5, 1, 5, 10, 50].map(s => (
<button key={s} onClick={() => setSpeed(s)} className={`px-2 py-1 rounded ${speed === s ? 'bg-white dark:bg-gray-600 shadow-sm font-bold' : ''}`}>
{s}x
const skipped = skippedCellsRef.current;
console.log(`Skipped Cells: ${skipped.length}`);
const skippedByReason = skipped.reduce((acc: any, cell: any) => {
const r = cell.properties?._reason || cell.properties?.sim_skip_reason || 'Unknown';
acc[r] = (acc[r] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Skipped Breakdown:', skippedByReason);
console.log('Skipped Sample (first 5):', skipped.slice(0, 5).map((c: any) => c.properties));
console.log('------------------------------');
}}
disabled={isCalculating}
className="p-2 rounded bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
title={translate("Print Debug Info to Console")}
>
<Bug className="w-4 h-4" />
<span className="hidden sm:inline text-xs font-semibold pr-1"><T>Debug</T></span>
</button>
))}
)}
</div>
{canPlaybackSpeed && (
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded p-1 text-xs">
<FastForward className="w-3 h-3 text-gray-500 mr-1" />
{[0.5, 1, 5, 10, 50].map(s => (
<button key={s} onClick={() => setSpeed(s)} className={`px-2 py-1 rounded ${speed === s ? 'bg-white dark:bg-gray-600 shadow-sm font-bold' : ''}`}>
{s}x
</button>
))}
</div>
)}
</div>
);
}

View File

@ -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<HTMLInputElement>(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 (
<div className="space-y-3">
<div className="flex justify-between items-center bg-gray-50 dark:bg-gray-800/50 p-2 rounded border border-gray-100 dark:border-gray-700/50">
<div className="flex justify-between items-center bg-gray-50 dark:bg-gray-800/50 p-2 rounded border border-gray-100 dark:border-gray-700/50 mb-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
<T>Settings</T>
</h4>
<div className="flex bg-gray-200 dark:bg-gray-700 p-0.5 rounded-md">
<button
onClick={() => handleModeChange(false)}
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] font-medium rounded-sm transition-colors ${!isAdvanced ? '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'}`}
>
<Sparkles className="w-3 h-3" />
<T>Simple</T>
</button>
<button
onClick={() => handleModeChange(true)}
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] font-medium rounded-sm transition-colors ${isAdvanced ? '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'}`}
>
<Settings2 className="w-3 h-3" />
<T>Advanced</T>
</button>
</div>
<div className="flex gap-2">
<button onClick={handleCopySettings} className="text-gray-400 hover:text-sky-600 dark:hover:text-sky-400" title={translate("Copy settings to clipboard")}>
<Copy className="w-4 h-4" />
@ -74,82 +111,88 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
</div>
</div>
<div className="flex flex-col gap-3 mb-3 mt-2">
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Grid Simulation Mode</T></label>
<div className="flex gap-2">
<button
onClick={() => props.applySettings({ gridMode: 'hex' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'hex' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Hex Geometric Grid</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'square' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'square' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Square Geometric Grid</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'admin' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'admin' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Native GADM Regions</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'centers' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'centers' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>GHS Centers</T>
</button>
<div className="flex flex-col gap-3 mb-3">
{isAdvanced && (
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Grid Simulation Mode</T></label>
<div className="flex gap-2">
<button
onClick={() => props.applySettings({ gridMode: 'hex' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'hex' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Hex Geometric Grid</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'square' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'square' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Square Geometric Grid</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'admin' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'admin' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Native GADM Regions</T>
</button>
<button
onClick={() => props.applySettings({ gridMode: 'centers' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.gridMode === 'centers' ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>GHS Centers</T>
</button>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Scan Trajectory</T></label>
<div className="flex gap-2 mb-2">
<button
onClick={() => props.applySettings({ pathOrder: 'zigzag' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'zigzag' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Zig-Zag</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'snake' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'snake' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Snake</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'spiral-out' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'spiral-out' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral Out</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'spiral-in' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'spiral-in' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral In</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'shortest' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'shortest' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Shortest</T>
</button>
{isAdvanced && (
<div className="bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-2"><T>Scan Trajectory</T></label>
<div className="flex gap-2 mb-2">
<button
onClick={() => props.applySettings({ pathOrder: 'zigzag' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'zigzag' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Zig-Zag</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'snake' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'snake' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Snake</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'spiral-out' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'spiral-out' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral Out</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'spiral-in' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'spiral-in' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Spiral In</T>
</button>
<button
onClick={() => props.applySettings({ pathOrder: 'shortest' })}
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.settings.pathOrder === 'shortest' ? 'bg-indigo-100 text-indigo-700 border border-indigo-300 dark:bg-indigo-900/30 dark:text-indigo-300 dark:border-indigo-800' : 'bg-gray-100 text-gray-600 border border-transparent hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}`}
>
<T>Shortest</T>
</button>
</div>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mt-2 cursor-pointer">
<input
type="checkbox"
checked={props.settings.groupByRegion}
onChange={e => props.applySettings({ groupByRegion: e.target.checked })}
className="mr-2"
/>
<T>Constrain process sequentially per boundary</T>
</label>
</div>
)}
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mt-2 cursor-pointer">
<input
type="checkbox"
checked={props.settings.groupByRegion}
onChange={e => props.applySettings({ groupByRegion: e.target.checked })}
className="mr-2"
/>
<T>Constrain process sequentially per boundary</T>
</label>
{isAdvanced && (
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t dark:border-gray-700">
<div className={props.settings.enableElevation ? '' : 'opacity-40'}>
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
@ -250,21 +293,28 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
</div>
</div>
</div>
</div>
)}
</div>
<div className={`bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md space-y-3 transition-opacity ${props.settings.gridMode === 'admin' ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Cell Base Size (km)</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.settings.cellSize}
onChange={(num: number) => props.applySettings({ cellSize: num })}
min={0.5}
step={0.5}
disabled={props.settings.gridMode === 'admin'}
/>
</div>
<div className={`bg-white dark:bg-gray-800 p-2 border dark:border-gray-700 rounded-md space-y-3 transition-opacity ${props.settings.gridMode === 'admin' ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1">
<T>Resolution (km)</T>: <span className="text-blue-600 dark:text-blue-400 font-medium">{props.settings.cellSize}</span>
</label>
<Slider
className="w-full py-2"
value={[props.settings.cellSize]}
onValueChange={(val: number[]) => props.applySettings({ cellSize: val[0] })}
min={1}
max={1000}
step={1}
disabled={props.settings.gridMode === 'admin'}
/>
</div>
</div>
{isAdvanced && (
<div className="grid grid-cols-2 gap-4 mt-2 border-t dark:border-gray-700 pt-3">
{props.settings.gridMode === 'centers' ? (
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Centroid Overlap Allowed %</T></label>
@ -297,18 +347,18 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
/>
</div>
)}
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Grid Generation Limit</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.settings.maxCellsLimit}
onChange={(num: number) => props.applySettings({ maxCellsLimit: num })}
step={500}
disabled={props.settings.gridMode === 'admin'}
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Grid Generation Limit</T></label>
<DeferredNumberInput
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
value={props.settings.maxCellsLimit}
onChange={(num: number) => props.applySettings({ maxCellsLimit: num })}
step={500}
disabled={props.settings.gridMode === 'admin'}
/>
</div>
</div>
)}
</div>
</div>
);

View File

@ -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<GridSimulatorSettings>('pm_gridSettings', defaultSettings);

View File

@ -18,4 +18,6 @@ export interface GridSearchSimulatorProps {
setSimulatorPath: (data: FeatureCollection<LineString, any>) => void;
setSimulatorScanner: (data: FeatureCollection<Point, any>) => void;
initialSettings?: Partial<GridSimulatorSettings>;
canDebug?: boolean;
canPlaybackSpeed?: boolean;
}