report | stats
This commit is contained in:
parent
4fc41f2eeb
commit
3b4e53be76
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 || [];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
640
packages/ui/src/modules/places/gadm-picker/GadmTreePicker.tsx
Normal file
640
packages/ui/src/modules/places/gadm-picker/GadmTreePicker.tsx
Normal 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';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -137,6 +137,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
|
||||
{viewMode === 'map' && (
|
||||
<CompetitorsMapView
|
||||
preset="SearchView"
|
||||
competitors={competitors}
|
||||
onMapCenterUpdate={handleMapCenterUpdate}
|
||||
enrich={dummyEnrich}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user