Maintenance Love :)
This commit is contained in:
parent
5503c6e729
commit
784bd4d903
@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid } from 'lucide-react';
|
||||
import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid, X } from 'lucide-react';
|
||||
import { type CompetitorFull } from '@polymech/shared';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LocationDetailView } from './LocationDetail';
|
||||
import { InfoPanel } from './InfoPanel';
|
||||
import { GadmPicker } from './gadm-picker';
|
||||
@ -26,7 +27,7 @@ const safeSetStyle = (m: maplibregl.Map, style: any) => {
|
||||
if (terrain) {
|
||||
m.once('style.load', () => {
|
||||
setTimeout(() => {
|
||||
try { m.setTerrain(terrain); } catch(e) {}
|
||||
try { m.setTerrain(terrain); } catch (e) { }
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
@ -119,18 +120,27 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const mapWrapper = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||
const [mapStyleKey, setMapStyleKey] = useState<MapStyleKey>(currentTheme === 'dark' ? 'dark' : 'light');
|
||||
const { theme } = useTheme();
|
||||
const [mapStyleKey, setMapStyleKey] = useState<MapStyleKey>('light');
|
||||
|
||||
useEffect(() => {
|
||||
setMapStyleKey(currentTheme === 'dark' ? 'dark' : 'light');
|
||||
}, [currentTheme]);
|
||||
const updateTheme = () => {
|
||||
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
setMapStyleKey(isDark ? 'dark' : 'light');
|
||||
};
|
||||
updateTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", updateTheme);
|
||||
return () => mediaQuery.removeEventListener("change", updateTheme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Selection and Sidebar State
|
||||
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(null);
|
||||
const [gadmPickerActive, setGadmPickerActive] = useState(false);
|
||||
|
||||
|
||||
// Grid Search Simulator State
|
||||
const [simulatorActive, setSimulatorActive] = useState(false);
|
||||
const [pickerRegions, setPickerRegions] = useState<any[]>([]);
|
||||
@ -138,7 +148,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
const [simulatorData, setSimulatorData] = useState<any>(null);
|
||||
const [simulatorPath, setSimulatorPath] = useState<any>(null);
|
||||
const [simulatorScanner, setSimulatorScanner] = useState<any>(null);
|
||||
|
||||
|
||||
// Layer Toggles
|
||||
const [showDensity, setShowDensity] = useState(false);
|
||||
const [showCenters, setShowCenters] = useState(false);
|
||||
@ -299,7 +309,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
// Handle Layout Resize
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
map.current.resize();
|
||||
setTimeout(() => map.current?.resize(), 50); // Accommodate flexbox reflow
|
||||
if (selectedLocation) {
|
||||
// Adjust padding to account for sidebar
|
||||
map.current.easeTo({
|
||||
@ -308,7 +318,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedLocation, sidebarWidth]);
|
||||
}, [selectedLocation, sidebarWidth, gadmPickerActive, simulatorActive]);
|
||||
|
||||
// Resizing Handlers
|
||||
const startResizing = useCallback(() => setIsResizing(true), []);
|
||||
@ -348,82 +358,138 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex-1 w-full h-full flex flex-col min-h-0">
|
||||
<style>{`
|
||||
.map-popup-override { z-index: 20 !important; }
|
||||
.map-popup-override .maplibregl-popup-content { padding: 0; border-radius: 0.5rem; }
|
||||
`}</style>
|
||||
|
||||
{/* Split View Container */}
|
||||
<div ref={mapWrapper} className="flex flex-col lg:flex-row h-[600px] [&:fullscreen]:h-screen border rounded-lg [&:fullscreen]:rounded-none [&:fullscreen]:border-none overflow-hidden shadow-md border-gray-200 dark:border-gray-700 relative bg-white dark:bg-gray-900">
|
||||
<div ref={mapWrapper} className="flex-1 w-full h-full min-h-[600px] flex flex-col xl:flex-row [&:fullscreen]:h-screen overflow-hidden relative bg-white dark:bg-gray-900">
|
||||
|
||||
{/* Map Panel */}
|
||||
{/* Map Panel */}
|
||||
<div className="relative h-full flex flex-col transition-all duration-75 bg-white dark:bg-gray-900" style={{ width: (selectedLocation || infoPanelOpen) ? `calc(100% - ${sidebarWidth}px)` : '100%' }}>
|
||||
{/* Left Tools Sidebar */}
|
||||
{(gadmPickerActive || simulatorActive) && (
|
||||
<div className="w-96 flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 z-10 overflow-hidden shadow-xl transition-all duration-300">
|
||||
<Tabs
|
||||
value={gadmPickerActive ? 'gadm' : 'simulator'}
|
||||
onValueChange={(val) => {
|
||||
setGadmPickerActive(val === 'gadm');
|
||||
setSimulatorActive(val === 'simulator');
|
||||
}}
|
||||
className="flex flex-col h-full w-full"
|
||||
>
|
||||
<div className="p-2 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50 flex-shrink-0">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="gadm" className="text-xs">Area Selector</TabsTrigger>
|
||||
<TabsTrigger value="simulator" className="text-xs">Grid Search</TabsTrigger>
|
||||
</TabsList>
|
||||
<button
|
||||
onClick={() => { setGadmPickerActive(false); setSimulatorActive(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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onClose={() => setGadmPickerActive(false)}
|
||||
onSelectionChange={(r, p) => {
|
||||
setPickerRegions(r);
|
||||
setPickerPolygons(p);
|
||||
}}
|
||||
initialRegions={initialGadmRegions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="simulator" forceMount className="data-[state=inactive]:hidden flex-1 overflow-y-auto m-0 p-4 border-none outline-none custom-scrollbar">
|
||||
{(pickerRegions && pickerRegions.length > 0) ? (
|
||||
<GridSearchSimulator
|
||||
pickerRegions={pickerRegions}
|
||||
pickerPolygons={pickerPolygons}
|
||||
setSimulatorData={setSimulatorData}
|
||||
setSimulatorPath={setSimulatorPath}
|
||||
setSimulatorScanner={setSimulatorScanner}
|
||||
onFilterCell={() => true}
|
||||
initialSettings={initialSimulatorSettings}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
Please select a region using the Area Selector first to define the search bounds.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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="flex items-center gap-3 p-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 z-20">
|
||||
<div className="w-full max-w-sm flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setGadmPickerActive(!gadmPickerActive);
|
||||
if (!gadmPickerActive) setSimulatorActive(false);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${gadmPickerActive
|
||||
<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 GADM Area Selector"
|
||||
>
|
||||
<MapIcon className="w-4 h-4" />
|
||||
{gadmPickerActive ? 'Close Selector' : 'Area Selector'}
|
||||
</button>
|
||||
}`}
|
||||
title="Toggle Tools Sidebar"
|
||||
>
|
||||
{(gadmPickerActive || simulatorActive) ? <X className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}
|
||||
Tools
|
||||
</button>
|
||||
|
||||
{/* Grid Search Toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSimulatorActive(!simulatorActive);
|
||||
if (!simulatorActive) setGadmPickerActive(false);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${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 Grid Search Simulator"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
Grid Search
|
||||
</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>
|
||||
|
||||
{/* 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 className="flex-1"></div> { /* Spacer */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div> { /* Spacer */}
|
||||
<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>
|
||||
|
||||
{/* Map Viewport */}
|
||||
<div className="relative flex-1 min-h-0 w-full">
|
||||
<div className="relative flex-1 min-h-0 w-full overflow-hidden">
|
||||
<div ref={mapContainer} className="w-full h-full" />
|
||||
|
||||
|
||||
{map.current && (
|
||||
<>
|
||||
<SimulatorLayers
|
||||
map={map.current}
|
||||
isDarkStyle={mapStyleKey === 'dark'}
|
||||
simulatorData={simulatorData}
|
||||
simulatorPath={simulatorPath}
|
||||
simulatorScanner={simulatorScanner}
|
||||
<SimulatorLayers
|
||||
map={map.current}
|
||||
isDarkStyle={mapStyleKey === 'dark'}
|
||||
simulatorData={simulatorData}
|
||||
simulatorPath={simulatorPath}
|
||||
simulatorScanner={simulatorScanner}
|
||||
/>
|
||||
<RegionLayers
|
||||
<RegionLayers
|
||||
map={map.current}
|
||||
isDarkStyle={mapStyleKey === 'dark'}
|
||||
pickerPolygons={pickerPolygons}
|
||||
@ -432,45 +498,6 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`absolute top-4 left-4 z-10 w-96 shadow-xl rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 transition-opacity duration-200 ${gadmPickerActive ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none hidden'}`}>
|
||||
<GadmPicker
|
||||
map={map.current}
|
||||
active={gadmPickerActive}
|
||||
onClose={() => setGadmPickerActive(false)}
|
||||
onSelectionChange={(r, p) => {
|
||||
setPickerRegions(r);
|
||||
setPickerPolygons(p);
|
||||
}}
|
||||
initialRegions={initialGadmRegions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{simulatorActive && (
|
||||
<div className="absolute top-4 left-4 z-10 w-96 max-h-[calc(100vh-200px)] overflow-y-auto shadow-xl rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 pb-2 custom-scrollbar">
|
||||
<div className="p-4 pb-2 flex justify-between items-center border-b dark:border-gray-700 mb-2">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-200">Grid Search Simulator</h3>
|
||||
<button onClick={() => setSimulatorActive(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm">Close</button>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{(pickerRegions && pickerRegions.length > 0) ? (
|
||||
<GridSearchSimulator
|
||||
pickerRegions={pickerRegions}
|
||||
pickerPolygons={pickerPolygons}
|
||||
setSimulatorData={setSimulatorData}
|
||||
setSimulatorPath={setSimulatorPath}
|
||||
setSimulatorScanner={setSimulatorScanner}
|
||||
onFilterCell={() => true}
|
||||
initialSettings={initialSimulatorSettings}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-sm text-gray-500 text-center">
|
||||
Please select a region using the Area Selector first to define the search bounds.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Status Info & Toggles */}
|
||||
@ -514,7 +541,6 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
</MapFooter>
|
||||
</div>
|
||||
|
||||
{/* Resizable Handle and Property Pane */}
|
||||
{/* Resizable Handle and Property Pane */}
|
||||
{(selectedLocation || infoPanelOpen) && (
|
||||
<>
|
||||
@ -546,57 +572,6 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend / Location List */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center mb-4">
|
||||
<MapIcon className="w-5 h-5 mr-2 text-indigo-500" />
|
||||
Locations ({validLocations.length})
|
||||
</h3>
|
||||
|
||||
{Object.keys(locationsByCity).length > 0 ? (
|
||||
Object.entries(locationsByCity).map(([city, locations]) => (
|
||||
<div key={city} className="bg-white dark:bg-gray-800 border rounded-lg border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{city} <span className="text-gray-500 font-normal text-xs ml-1">({locations.length})</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.place_id} className={`flex items-start space-x-3 p-2 rounded-lg transition-colors cursor-pointer ${selectedLocation?.place_id === loc.place_id ? 'bg-indigo-50 dark:bg-indigo-900/30 ring-1 ring-indigo-500' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}
|
||||
onClick={() => {
|
||||
if (map.current) {
|
||||
map.current.flyTo({ center: [loc.lon, loc.lat], zoom: 15 });
|
||||
}
|
||||
setSelectedLocation(loc);
|
||||
mapContainer.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}}>
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-red-600 text-white text-xs font-bold shadow-sm">
|
||||
{loc.label}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-indigo-600 truncate">{loc.title}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1 mb-1">
|
||||
{loc.types?.slice(0, 2).map(t => (
|
||||
<span key={t} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 capitalize">
|
||||
{t.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">{loc.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Loading locations...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -188,3 +188,13 @@ export const fetchCompetitorById = async (id: string): Promise<any> => {
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
};
|
||||
|
||||
export const fetchGridSearchExport = async (jobId: string, format: 'md' | 'json' = 'md'): Promise<string> => {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${serverUrl}/api/locations/gridsearch/export?search=${jobId}&format=${format}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to load report: ${res.statusText}`);
|
||||
return await res.text();
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { Users, MousePointerClick, MapPin, Palette } from 'lucide-react';
|
||||
import { MAP_STYLES, type MapStyleKey } from './map-styles';
|
||||
import { POSTER_THEMES } from '../utils/poster-themes';
|
||||
@ -81,11 +81,18 @@ export function GridSearchMap({
|
||||
const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls();
|
||||
|
||||
// Theme / map style
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getResolvedTheme = useCallback(() => {
|
||||
if (theme === 'system' && typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}, [theme]);
|
||||
|
||||
const [mapStyleKey, setMapStyleKeyRaw] = useState<MapStyleKey>(() => {
|
||||
if (urlStyle === 'dark' || urlStyle === 'light') return urlStyle as MapStyleKey;
|
||||
return currentTheme === 'dark' ? 'dark' : 'light';
|
||||
return getResolvedTheme();
|
||||
});
|
||||
|
||||
const setMapStyleKey = useCallback((styleKey: MapStyleKey) => {
|
||||
@ -99,9 +106,18 @@ export function GridSearchMap({
|
||||
}, [mapStyleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlStyle) return;
|
||||
setMapStyleKeyRaw(currentTheme === 'dark' ? 'dark' : 'light');
|
||||
}, [currentTheme, urlStyle]);
|
||||
const updateTheme = () => {
|
||||
if (urlStyle) return;
|
||||
setMapStyleKeyRaw(getResolvedTheme());
|
||||
};
|
||||
updateTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", updateTheme);
|
||||
return () => mediaQuery.removeEventListener("change", updateTheme);
|
||||
}
|
||||
}, [theme, urlStyle, getResolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const m = map.current;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { Loader2, X, MapPin, Crosshair, Plus, ChevronRight, Copy, Download, Upload, Trash2 } from 'lucide-react';
|
||||
import * as turf from '@turf/turf';
|
||||
import { searchGadmRegions, fetchRegionHierarchy, fetchRegionBoundary } from '../client-searches';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GadmSearchControls } from './components/GadmSearchControls';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
@ -102,7 +103,7 @@ const LEVEL_OPTIONS = [
|
||||
export function GadmPicker({ map, active, onClose, onSelectionChange, className = "", initialRegions }: GadmPickerProps) {
|
||||
const [levelOption, setLevelOption] = useLocalStorage<number>('pm_gadm_levelOption', 0);
|
||||
const [resolutionOption, setResolutionOption] = useLocalStorage<number>('pm_gadm_resolutionOption', 1);
|
||||
const [enrich, setEnrich] = useLocalStorage<boolean>('pm_gadm_enrich', true);
|
||||
const enrich = true;
|
||||
|
||||
// UI state
|
||||
const [query, setQuery] = useState('');
|
||||
@ -110,6 +111,12 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const suggestionsWrapRef = useRef<HTMLDivElement>(null);
|
||||
const headerSuggestionsWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderPortalTarget(document.getElementById('gadm-controls-portal'));
|
||||
}, []);
|
||||
|
||||
const [selectedRegions, setSelectedRegions] = useState<GadmRegion[]>([]);
|
||||
const [geojsons, setGeojsons] = useState<Record<string, any>>({});
|
||||
@ -369,7 +376,10 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
// Close suggestions
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (suggestionsWrapRef.current && !suggestionsWrapRef.current.contains(e.target as Node)) {
|
||||
const tgt = e.target as Node;
|
||||
const inMain = suggestionsWrapRef.current?.contains(tgt);
|
||||
const inHeader = headerSuggestionsWrapRef.current?.contains(tgt);
|
||||
if (!inMain && !inHeader) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
@ -556,9 +566,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
updateMapFeatures();
|
||||
}, [updateMapFeatures]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
const sidebarContent = active ? (
|
||||
<div className={`p-4 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-sm w-full h-full max-h-[80vh] flex flex-col space-y-4 ${className}`}>
|
||||
<div className="flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-200">GADM Area Selector</h3>
|
||||
@ -574,78 +582,16 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
</p>
|
||||
|
||||
{/* Config & Search */}
|
||||
<div className="flex flex-col gap-3 flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 block mb-1">Search Level</label>
|
||||
<select
|
||||
className="w-full border rounded-md p-1.5 text-sm dark:bg-gray-900 dark:border-gray-600 dark:text-white"
|
||||
value={levelOption}
|
||||
onChange={e => setLevelOption(Number(e.target.value))}
|
||||
>
|
||||
{LEVEL_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 block mb-1">Resolve At</label>
|
||||
<select
|
||||
className="w-full border rounded-md p-1.5 text-sm dark:bg-gray-900 dark:border-gray-600 dark:text-white"
|
||||
value={resolutionOption}
|
||||
onChange={e => setResolutionOption(Number(e.target.value))}
|
||||
>
|
||||
{LEVEL_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="picker-enrich"
|
||||
checked={enrich}
|
||||
onCheckedChange={setEnrich}
|
||||
className="data-[state=checked]:bg-sky-500"
|
||||
/>
|
||||
<Label htmlFor="picker-enrich" className="text-xs text-sky-700 dark:text-sky-300 font-medium cursor-pointer">
|
||||
Enrich with GHS data
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20" ref={suggestionsWrapRef}>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${LEVEL_OPTIONS.find(o => o.value === levelOption)?.label}...`}
|
||||
className="w-full pl-9 pr-10 py-2 border rounded-md text-sm bg-gray-50 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setShowSuggestions(true); }}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
/>
|
||||
{loadingSuggestions && <div className="absolute right-3 top-2.5"><Loader2 className="h-4 w-4 animate-spin text-gray-400" /></div>}
|
||||
</div>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||
{suggestions.map((s, i) => {
|
||||
const nameMatches = Object.keys(s).filter(k => k.startsWith('NAME_')).sort().reverse();
|
||||
const primaryName = nameMatches.length > 0 ? s[nameMatches[0]] : (s.name || s.gadmName);
|
||||
const subText = nameMatches.length > 1 ? s[nameMatches[nameMatches.length - 1]] : '';
|
||||
const gidKey = Object.keys(s).find(k => k.startsWith('GID_'));
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 border-b dark:border-gray-700 last:border-0"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelectRegion(s, undefined, e.ctrlKey || e.metaKey); }}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{primaryName || "Unknown"}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{subText ? `${subText} • ` : ''}{gidKey && s[gidKey] ? s[gidKey] : ''}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GadmSearchControls
|
||||
levelOption={levelOption} setLevelOption={setLevelOption}
|
||||
resolutionOption={resolutionOption} setResolutionOption={setResolutionOption}
|
||||
query={query} setQuery={setQuery}
|
||||
suggestions={suggestions} loadingSuggestions={loadingSuggestions}
|
||||
showSuggestions={showSuggestions} setShowSuggestions={setShowSuggestions}
|
||||
onSelectSuggestion={(s, ctrlKey) => handleSelectRegion(s, undefined, ctrlKey)}
|
||||
suggestionsWrapRef={suggestionsWrapRef}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 space-y-4 pr-2">
|
||||
{/* Visual Inspector Panel */}
|
||||
@ -766,5 +712,26 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sidebarContent}
|
||||
|
||||
{/* Portal Config & Search to Header */}
|
||||
{headerPortalTarget && createPortal(
|
||||
<GadmSearchControls
|
||||
levelOption={levelOption} setLevelOption={setLevelOption}
|
||||
resolutionOption={resolutionOption} setResolutionOption={setResolutionOption}
|
||||
query={query} setQuery={setQuery}
|
||||
suggestions={suggestions} loadingSuggestions={loadingSuggestions}
|
||||
showSuggestions={showSuggestions} setShowSuggestions={setShowSuggestions}
|
||||
onSelectSuggestion={(s, ctrlKey) => handleSelectRegion(s, undefined, ctrlKey)}
|
||||
suggestionsWrapRef={headerSuggestionsWrapRef}
|
||||
layout="horizontal"
|
||||
/>,
|
||||
headerPortalTarget
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { MapPin, Loader2 } from 'lucide-react';
|
||||
|
||||
export const LEVEL_OPTIONS = [
|
||||
{ value: 0, label: 'Country (Level 0)' },
|
||||
{ value: 1, label: 'State (Level 1)' },
|
||||
{ value: 2, label: 'Province (Level 2)' },
|
||||
{ value: 3, label: 'District (Level 3)' },
|
||||
{ value: 4, label: 'City (Level 4)' },
|
||||
{ value: 5, label: 'Town (Level 5)' },
|
||||
];
|
||||
|
||||
export interface GadmSearchControlsProps {
|
||||
levelOption: number;
|
||||
setLevelOption: (v: number) => void;
|
||||
resolutionOption: number;
|
||||
setResolutionOption: (v: number) => void;
|
||||
query: string;
|
||||
setQuery: (v: string) => void;
|
||||
suggestions: any[];
|
||||
loadingSuggestions: boolean;
|
||||
showSuggestions: boolean;
|
||||
setShowSuggestions: (v: boolean) => void;
|
||||
onSelectSuggestion: (s: any, ctrlKey: boolean) => void;
|
||||
suggestionsWrapRef: React.RefObject<HTMLDivElement>;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export function GadmSearchControls({
|
||||
levelOption, setLevelOption,
|
||||
resolutionOption, setResolutionOption,
|
||||
query, setQuery,
|
||||
suggestions, loadingSuggestions, showSuggestions, setShowSuggestions,
|
||||
onSelectSuggestion, suggestionsWrapRef,
|
||||
layout = 'vertical'
|
||||
}: GadmSearchControlsProps) {
|
||||
const isHorizontal = layout === 'horizontal';
|
||||
|
||||
const renderSegments = (value: number, setValue: (v: number) => void, label: string) => (
|
||||
<div className={`flex ${isHorizontal ? 'items-center gap-2' : 'flex-col gap-1'}`}>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">{label}</span>
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 p-0.5 rounded-md border dark:border-gray-700">
|
||||
{LEVEL_OPTIONS.map(opt => (
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
L{opt.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${isHorizontal ? 'flex-row items-center gap-4' : 'flex-col gap-4'} flex-shrink-0`}>
|
||||
{/* Search Input */}
|
||||
<div className={`relative z-20 ${isHorizontal ? 'w-64' : ''}`} ref={suggestionsWrapRef}>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${LEVEL_OPTIONS.find(o => o.value === levelOption)?.label}...`}
|
||||
className="w-full pl-9 pr-10 py-1.5 border rounded-md text-sm bg-gray-50 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setShowSuggestions(true); }}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
/>
|
||||
{loadingSuggestions && <div className="absolute right-3 top-2.5"><Loader2 className="h-4 w-4 animate-spin text-gray-400" /></div>}
|
||||
</div>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-y-auto w-[150%]">
|
||||
{suggestions.map((s, i) => {
|
||||
const nameMatches = Object.keys(s).filter(k => k.startsWith('NAME_')).sort().reverse();
|
||||
const primaryName = nameMatches.length > 0 ? s[nameMatches[0]] : (s.name || s.gadmName);
|
||||
const subText = nameMatches.length > 1 ? s[nameMatches[nameMatches.length - 1]] : '';
|
||||
const gidKey = Object.keys(s).find(k => k.startsWith('GID_'));
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 border-b dark:border-gray-700 last:border-0"
|
||||
onMouseDown={(e) => { e.preventDefault(); onSelectSuggestion(s, e.ctrlKey || e.metaKey); }}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{primaryName || "Unknown"}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{subText ? `${subText} • ` : ''}{gidKey && s[gidKey] ? s[gidKey] : ''}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Loader2, AlertCircle, Download } from 'lucide-react';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||
import { fetchGridSearchExport } from '../client-gridsearch';
|
||||
|
||||
export function CompetitorsReportView({ jobId }: { jobId: string }) {
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const text = await fetchGridSearchExport(jobId, 'md');
|
||||
|
||||
if (active) {
|
||||
setMarkdown(text);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (active) setError(err.message || 'Failed to generate report');
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [jobId]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!markdown) return;
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `GridSearch_Report_${jobId}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 w-full h-full">
|
||||
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin mb-4" />
|
||||
<p className="text-gray-500">Generating report...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-lg flex items-start gap-3 border border-red-200">
|
||||
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">Failed to load report</h4>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full h-full overflow-y-auto pb-24">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">Grid Search Report</h2>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50 rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download .md
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 prose-container overflow-hidden">
|
||||
{markdown ? (
|
||||
<MarkdownRenderer content={markdown} />
|
||||
) : (
|
||||
<p className="text-gray-500">No report content available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
import { PlusCircle, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { OngoingSearches } from './OngoingSearches';
|
||||
import { GridSearchWizard } from './GridSearchWizard';
|
||||
import { JobViewer } from './JobViewer';
|
||||
@ -12,6 +12,10 @@ export default function GridSearch() {
|
||||
const saved = localStorage.getItem('gridSearchSidebarWidth');
|
||||
return saved ? parseInt(saved, 10) : 320;
|
||||
});
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(() => {
|
||||
const saved = localStorage.getItem('gridSearchSidebarOpen');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const { setShowGlobalFooter } = useAppStore();
|
||||
@ -43,6 +47,10 @@ export default function GridSearch() {
|
||||
}
|
||||
}, [sidebarWidth, isResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('gridSearchSidebarOpen', isSidebarOpen.toString());
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
const startResizing = useCallback((shouldStart: boolean) => {
|
||||
setIsResizing(shouldStart);
|
||||
}, []);
|
||||
@ -92,9 +100,7 @@ export default function GridSearch() {
|
||||
const handleDrag = (e: MouseEvent) => {
|
||||
if (!isResizing || !sidebarRef.current) return;
|
||||
const sidebarRect = sidebarRef.current.getBoundingClientRect();
|
||||
let newWidth = e.clientX - sidebarRect.left;
|
||||
if (newWidth < 250) newWidth = 250;
|
||||
if (newWidth > 600) newWidth = 600;
|
||||
let newWidth = Math.max(0, e.clientX - sidebarRect.left);
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
@ -110,8 +116,8 @@ export default function GridSearch() {
|
||||
{/* Left Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
className={`flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative transition-[width] duration-300 ${!isSidebarOpen ? 'overflow-hidden' : ''}`}
|
||||
style={{ width: isSidebarOpen ? `${sidebarWidth}px` : '0px' }}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
|
||||
<button
|
||||
@ -136,15 +142,26 @@ export default function GridSearch() {
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="absolute top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 16}px)` : 'translateX(0)' }}
|
||||
>
|
||||
{isSidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<RestoredSearchProvider jobId={selectedJobId}>
|
||||
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-y-auto overflow-x-hidden bg-gradient-to-br from-indigo-50/50 via-white to-blue-50/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800">
|
||||
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden bg-gradient-to-br from-indigo-50/50 via-white to-blue-50/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800">
|
||||
{selectedJobId ? (
|
||||
<JobViewer jobId={selectedJobId} />
|
||||
) : (
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart } from 'lucide-react';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText } from 'lucide-react';
|
||||
|
||||
import { CompetitorsGridView } from '../CompetitorsGridView';
|
||||
import { CompetitorsMapView } from '../CompetitorsMapView';
|
||||
import { CompetitorsThumbView } from '../CompetitorsThumbView';
|
||||
import { CompetitorsMetaView } from '../CompetitorsMetaView';
|
||||
import { CompetitorsReportView } from './CompetitorsReportView';
|
||||
import { useRestoredSearch } from './RestoredSearchContext';
|
||||
|
||||
import { type CompetitorFull } from '@polymech/shared';
|
||||
|
||||
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta';
|
||||
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report';
|
||||
|
||||
interface GridSearchResultsProps {
|
||||
jobId: string;
|
||||
@ -36,10 +37,10 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
// Initialize viewMode: URL -> LocalStorage -> 'grid'
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const urlView = searchParams.get('view') as ViewMode;
|
||||
if (urlView && ['grid', 'thumb', 'map', 'meta'].includes(urlView)) return urlView;
|
||||
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report'].includes(urlView)) return urlView;
|
||||
|
||||
const localView = localStorage.getItem('gridSearchViewMode') as ViewMode;
|
||||
if (localView && ['grid', 'thumb', 'map', 'meta'].includes(localView)) return localView;
|
||||
if (localView && ['grid', 'thumb', 'map', 'meta', 'report'].includes(localView)) return localView;
|
||||
|
||||
return 'grid';
|
||||
});
|
||||
@ -57,14 +58,14 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
const [showExcluded, setShowExcluded] = useState(false);
|
||||
|
||||
// Mock functions for now till we have real enrichment tracking in grid search results
|
||||
const dummyEnrich = async () => {};
|
||||
const handleMapCenterUpdate = () => {};
|
||||
const dummyEnrich = async () => { };
|
||||
const handleMapCenterUpdate = () => { };
|
||||
|
||||
const settings = { ...MOCK_SETTINGS, excluded_types: excludedTypes };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden w-full relative">
|
||||
<div className="flex justify-end mb-4 px-2">
|
||||
<div className="flex justify-end mb-4 mt-2 px-2">
|
||||
<div className="flex items-center gap-3 p-1 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-800">
|
||||
<span className="text-xs text-gray-500 px-2">
|
||||
{competitors.length} results
|
||||
@ -99,18 +100,25 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
>
|
||||
<PieChart className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('report')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'report' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Markdown Report"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gridsearch-scroll-area" className="flex-1 overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
{viewMode === 'grid' && (
|
||||
<CompetitorsGridView
|
||||
competitors={competitors}
|
||||
loading={false}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => {})}
|
||||
onOpenSettings={() => {}}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
onOpenSettings={() => { }}
|
||||
showExcluded={showExcluded}
|
||||
setShowExcluded={setShowExcluded}
|
||||
enrich={dummyEnrich}
|
||||
@ -123,7 +131,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<CompetitorsThumbView
|
||||
competitors={competitors}
|
||||
filters={[]}
|
||||
toggleFilter={() => {}}
|
||||
toggleFilter={() => { }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -162,9 +170,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<CompetitorsMetaView
|
||||
competitors={competitors}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => {})}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'report' && (
|
||||
<CompetitorsReportView jobId={jobId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ import { fetchGridSearchById } from '../client-gridsearch';
|
||||
import { GridSearchResults } from './GridSearchResults';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useRestoredSearch } from './RestoredSearchContext';
|
||||
import CollapsibleSection from '@/components/CollapsibleSection';
|
||||
|
||||
export function JobViewer({ jobId }: { jobId: string }) {
|
||||
const navigate = useNavigate();
|
||||
@ -64,7 +65,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 h-full border-box w-full">
|
||||
<div className="flex flex-col items-center justify-center p-6 flex-1 w-full box-border min-h-0">
|
||||
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mb-4" />
|
||||
<p className="text-gray-500">Loading search results...</p>
|
||||
</div>
|
||||
@ -73,7 +74,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 h-full border-box w-full max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center justify-center p-6 flex-1 w-full box-border max-w-4xl mx-auto min-h-0">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full p-10 text-center space-y-6">
|
||||
<div className="w-20 h-20 bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle className="w-10 h-10" />
|
||||
@ -89,12 +90,15 @@ 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[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-6 h-full border-box w-full overflow-hidden">
|
||||
<div className="w-full flex-grow flex flex-col bg-white dark:bg-gray-800 rounded-2xl shadow-xl transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between p-6 pb-4 border-b dark:border-gray-700">
|
||||
<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">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between p-6 pb-2 border-b dark:border-gray-700">
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">Search Results</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -108,73 +112,92 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
New Search
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-medium">Job ID:</span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
|
||||
{jobData && (
|
||||
<>
|
||||
{jobData.request?.search?.types && (
|
||||
<p><span className="font-medium">Queries:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
|
||||
)}
|
||||
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
|
||||
<p><span className="font-medium">Region:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
|
||||
)}
|
||||
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
|
||||
<p><span className="font-medium">Level:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
|
||||
)}
|
||||
{jobData.areas && (
|
||||
<p><span className="font-medium">Areas Scanned:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
|
||||
)}
|
||||
{jobData.generatedAt && (
|
||||
<p><span className="font-medium">Generated At:</span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
|
||||
)}
|
||||
</>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Search Configuration & Metadata"
|
||||
minimal
|
||||
initiallyOpen={false}
|
||||
storageKey={`gridSearchViewMetaCollapse`}
|
||||
className="mt-2"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-medium">Job ID:</span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
|
||||
{jobData && (
|
||||
<>
|
||||
{jobData.request?.search?.types && (
|
||||
<p><span className="font-medium">Queries:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
|
||||
)}
|
||||
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
|
||||
<p><span className="font-medium">Region:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
|
||||
)}
|
||||
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
|
||||
<p><span className="font-medium">Level:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
|
||||
)}
|
||||
{jobData.areas && (
|
||||
<p><span className="font-medium">Areas Scanned:</span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
|
||||
)}
|
||||
{jobData.generatedAt && (
|
||||
<p><span className="font-medium">Generated At:</span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
|
||||
)}
|
||||
{foundTypes.length > 0 && (
|
||||
<div className="col-span-full mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="font-medium mr-1 text-gray-800 dark:text-gray-300">Found Types:</span>
|
||||
{foundTypes.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 rounded-md text-xs font-medium">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restored GADM Areas */}
|
||||
{guidedAreas.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
GADM Areas ({guidedAreas.length})
|
||||
</span>
|
||||
{isLocked && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
|
||||
<Lock className="w-3 h-3" /> Locked
|
||||
</span>
|
||||
)}
|
||||
{isComplete && (
|
||||
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{guidedAreas.map((area: any) => (
|
||||
<span
|
||||
key={area.gid}
|
||||
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
|
||||
title={area.gid}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||
{area.name}
|
||||
<span className="text-[10px] opacity-60">L{area.level}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restored GADM Areas */}
|
||||
{guidedAreas.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
GADM Areas ({guidedAreas.length})
|
||||
</span>
|
||||
{isLocked && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
|
||||
<Lock className="w-3 h-3" /> Locked
|
||||
</span>
|
||||
)}
|
||||
{isComplete && (
|
||||
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
{/* Search Settings Summary */}
|
||||
{searchSettings && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Cell: {searchSettings.cellSize}km</span>}
|
||||
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Min Pop: {searchSettings.minPopDensity}</span>}
|
||||
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Path: {searchSettings.pathOrder}</span>}
|
||||
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Grid: {searchSettings.gridMode}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{guidedAreas.map((area: any) => (
|
||||
<span
|
||||
key={area.gid}
|
||||
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
|
||||
title={area.gid}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||
{area.name}
|
||||
<span className="text-[10px] opacity-60">L{area.level}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Settings Summary */}
|
||||
{searchSettings && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Cell: {searchSettings.cellSize}km</span>}
|
||||
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Min Pop: {searchSettings.minPopDensity}</span>}
|
||||
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Path: {searchSettings.pathOrder}</span>}
|
||||
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">Grid: {searchSettings.gridMode}</span>}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow min-h-0 bg-gray-50 dark:bg-gray-900 border-t dark:border-gray-700">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useGridSimulatorState } from './hooks/useGridSimulatorState';
|
||||
import { SimulatorControls } from './components/SimulatorControls';
|
||||
@ -9,8 +10,13 @@ import { T, translate } from '@/i18n';
|
||||
|
||||
export function GridSearchSimulator(props: GridSearchSimulatorProps) {
|
||||
const state = useGridSimulatorState(props);
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
if (!props.pickerRegions || props.pickerRegions.length === 0) return null;
|
||||
useEffect(() => {
|
||||
setPortalTarget(document.getElementById('sim-controls-portal'));
|
||||
}, []);
|
||||
|
||||
const hasRegions = props.pickerRegions && props.pickerRegions.length > 0;
|
||||
|
||||
const handleCopyWaypoints = () => {
|
||||
const hops = state.getFinalHopList();
|
||||
@ -30,7 +36,7 @@ export function GridSearchSimulator(props: GridSearchSimulatorProps) {
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden pt-2 flex flex-col">
|
||||
<div className="space-y-4 pb-4 mb-4 border-b dark:border-gray-700">
|
||||
{portalTarget && createPortal(
|
||||
<SimulatorControls
|
||||
gridCells={state.gridCells}
|
||||
progressIndex={state.progressIndex}
|
||||
@ -44,38 +50,45 @@ export function GridSearchSimulator(props: GridSearchSimulatorProps) {
|
||||
setSpeed={state.setSpeed}
|
||||
computeGrid={state.computeGrid}
|
||||
handleClear={state.handleClear}
|
||||
/>
|
||||
<SimulatorStats
|
||||
progressIndex={state.progressIndex}
|
||||
totalCells={state.gridCells.length}
|
||||
validCount={state.validCount}
|
||||
skippedCount={state.skippedCount}
|
||||
processedCount={state.processedCount}
|
||||
handleCopyWaypoints={handleCopyWaypoints}
|
||||
handleExportWaypoints={handleExportWaypoints}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SimulatorSettingsPanel {...state} />
|
||||
|
||||
{state.isCalculating && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 bg-white/50 dark:bg-gray-900/50 flex flex-col items-center justify-center z-10 backdrop-blur-[1px]">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-lg border dark:border-gray-700 rounded-md p-4 flex flex-col items-center text-sm font-medium text-blue-600 dark:text-blue-400 max-w-xs text-center">
|
||||
<div className="flex items-center mb-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span>
|
||||
<T>Calculating...</T>
|
||||
{state.calcStats.total > 0 && <span className="block text-xs font-normal text-gray-500 mt-1">{state.calcStats.current} / {state.calcStats.total} <T>cells</T></span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { state.stopRequestedRef.current = true; }}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white py-1.5 px-3 rounded transition-colors text-xs font-semibold"
|
||||
>
|
||||
<T>Stop</T>
|
||||
</button>
|
||||
/>,
|
||||
portalTarget
|
||||
)}
|
||||
{hasRegions && (
|
||||
<>
|
||||
<div className="space-y-4 pb-4 mb-4 border-b dark:border-gray-700">
|
||||
<SimulatorStats
|
||||
progressIndex={state.progressIndex}
|
||||
totalCells={state.gridCells.length}
|
||||
validCount={state.validCount}
|
||||
skippedCount={state.skippedCount}
|
||||
processedCount={state.processedCount}
|
||||
handleCopyWaypoints={handleCopyWaypoints}
|
||||
handleExportWaypoints={handleExportWaypoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SimulatorSettingsPanel {...state} />
|
||||
|
||||
{state.isCalculating && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 bg-white/50 dark:bg-gray-900/50 flex flex-col items-center justify-center z-10 backdrop-blur-[1px]">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-lg border dark:border-gray-700 rounded-md p-4 flex flex-col items-center text-sm font-medium text-blue-600 dark:text-blue-400 max-w-xs text-center">
|
||||
<div className="flex items-center mb-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span>
|
||||
<T>Calculating...</T>
|
||||
{state.calcStats.total > 0 && <span className="block text-xs font-normal text-gray-500 mt-1">{state.calcStats.current} / {state.calcStats.total} <T>cells</T></span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { state.stopRequestedRef.current = true; }}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white py-1.5 px-3 rounded transition-colors text-xs font-semibold"
|
||||
>
|
||||
<T>Stop</T>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,48 +6,14 @@ import { T, translate } from '@/i18n';
|
||||
|
||||
interface SimulatorSettingsPanelProps {
|
||||
// Current State
|
||||
gridMode: string;
|
||||
pathOrder: string;
|
||||
groupByRegion: boolean;
|
||||
cellSize: number;
|
||||
cellOverlap: number;
|
||||
centroidOverlap: number;
|
||||
ghsFilterMode: string;
|
||||
maxCellsLimit: number;
|
||||
maxElevation: number;
|
||||
minDensity: number;
|
||||
minGhsPop: number;
|
||||
minGhsBuilt: number;
|
||||
enableElevation: boolean;
|
||||
enableDensity: boolean;
|
||||
enableGhsPop: boolean;
|
||||
enableGhsBuilt: boolean;
|
||||
allowMissingGhs: boolean;
|
||||
bypassFilters: boolean;
|
||||
settings: GridSimulatorSettings;
|
||||
ghsBounds: { minPop: number; maxPop: number; minBuilt: number; maxBuilt: number; };
|
||||
|
||||
// Setters
|
||||
setGridMode: (v: any) => void;
|
||||
setPathOrder: (v: any) => void;
|
||||
setGroupByRegion: (v: any) => void;
|
||||
setCellSize: (v: any) => void;
|
||||
setCellOverlap: (v: any) => void;
|
||||
setCentroidOverlap: (v: any) => void;
|
||||
setGhsFilterMode: (v: any) => void;
|
||||
setMaxCellsLimit: (v: any) => void;
|
||||
setMaxElevation: (v: any) => void;
|
||||
setMinDensity: (v: any) => void;
|
||||
setMinGhsPop: (v: any) => void;
|
||||
setMinGhsBuilt: (v: any) => void;
|
||||
setEnableElevation: (v: any) => void;
|
||||
setEnableDensity: (v: any) => void;
|
||||
setEnableGhsPop: (v: any) => void;
|
||||
setEnableGhsBuilt: (v: any) => void;
|
||||
setAllowMissingGhs: (v: any) => void;
|
||||
setBypassFilters: (v: any) => void;
|
||||
|
||||
// Actions
|
||||
getCurrentSettings: () => GridSimulatorSettings;
|
||||
applySettings: (settings: Partial<GridSimulatorSettings>) => void;
|
||||
}
|
||||
|
||||
export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
@ -75,24 +41,7 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const json = JSON.parse(evt.target?.result as string);
|
||||
if (json.gridMode) props.setGridMode(json.gridMode);
|
||||
if (json.pathOrder) props.setPathOrder(json.pathOrder);
|
||||
if (json.groupByRegion !== undefined) props.setGroupByRegion(json.groupByRegion);
|
||||
if (json.cellSize !== undefined) props.setCellSize(json.cellSize);
|
||||
if (json.cellOverlap !== undefined) props.setCellOverlap(json.cellOverlap);
|
||||
if (json.centroidOverlap !== undefined) props.setCentroidOverlap(json.centroidOverlap);
|
||||
if (json.ghsFilterMode) props.setGhsFilterMode(json.ghsFilterMode);
|
||||
if (json.maxCellsLimit !== undefined) props.setMaxCellsLimit(json.maxCellsLimit);
|
||||
if (json.maxElevation !== undefined) props.setMaxElevation(json.maxElevation);
|
||||
if (json.minDensity !== undefined) props.setMinDensity(json.minDensity);
|
||||
if (json.minGhsPop !== undefined) props.setMinGhsPop(json.minGhsPop);
|
||||
if (json.minGhsBuilt !== undefined) props.setMinGhsBuilt(json.minGhsBuilt);
|
||||
if (json.enableElevation !== undefined) props.setEnableElevation(json.enableElevation);
|
||||
if (json.enableDensity !== undefined) props.setEnableDensity(json.enableDensity);
|
||||
if (json.enableGhsPop !== undefined) props.setEnableGhsPop(json.enableGhsPop);
|
||||
if (json.enableGhsBuilt !== undefined) props.setEnableGhsBuilt(json.enableGhsBuilt);
|
||||
if (json.allowMissingGhs !== undefined) props.setAllowMissingGhs(json.allowMissingGhs);
|
||||
if (json.bypassFilters !== undefined) props.setBypassFilters(json.bypassFilters);
|
||||
props.applySettings(json);
|
||||
} catch (err) {
|
||||
alert(translate("Invalid JSON file"));
|
||||
console.error(err);
|
||||
@ -130,26 +79,26 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
<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.setGridMode('hex')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setGridMode('square')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setGridMode('admin')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setGridMode('centers')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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>
|
||||
@ -160,32 +109,32 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
<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.setPathOrder('zigzag')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setPathOrder('snake')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setPathOrder('spiral-out')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setPathOrder('spiral-in')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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.setPathOrder('shortest')}
|
||||
className={`flex-1 py-1.5 text-xs rounded transition-colors ${props.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'}`}
|
||||
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>
|
||||
@ -194,40 +143,40 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.groupByRegion}
|
||||
onChange={e => props.setGroupByRegion(e.target.checked)}
|
||||
checked={props.settings.groupByRegion}
|
||||
onChange={e => props.applySettings({ groupByRegion: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
<T>Constrain process sequentially per boundary</T>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t dark:border-gray-700">
|
||||
<div className={props.enableElevation ? '' : 'opacity-40'}>
|
||||
<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">
|
||||
<input type="checkbox" checked={props.enableElevation} onChange={(e) => props.setEnableElevation(e.target.checked)} className="mr-2" />
|
||||
<input type="checkbox" checked={props.settings.enableElevation} onChange={(e) => props.applySettings({ enableElevation: e.target.checked })} className="mr-2" />
|
||||
<T>Max Elevation (m)</T>
|
||||
</label>
|
||||
<div className={props.enableElevation ? '' : 'pointer-events-none select-none'}>
|
||||
<div className={props.settings.enableElevation ? '' : 'pointer-events-none select-none'}>
|
||||
<DeferredNumberInput
|
||||
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
|
||||
value={props.maxElevation}
|
||||
onChange={(num: number) => props.setMaxElevation(num)}
|
||||
value={props.settings.maxElevation}
|
||||
onChange={(num: number) => props.applySettings({ maxElevation: num })}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-500"><T>Drop ></T> {props.maxElevation}m</span>
|
||||
<span className="text-[10px] text-gray-500"><T>Drop ></T> {props.settings.maxElevation}m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={props.enableDensity ? '' : 'opacity-40'}>
|
||||
<div className={props.settings.enableDensity ? '' : 'opacity-40'}>
|
||||
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
|
||||
<input type="checkbox" checked={props.enableDensity} onChange={(e) => props.setEnableDensity(e.target.checked)} className="mr-2" />
|
||||
<input type="checkbox" checked={props.settings.enableDensity} onChange={(e) => props.applySettings({ enableDensity: e.target.checked })} className="mr-2" />
|
||||
<T>Min Density (p/km²)</T>
|
||||
</label>
|
||||
<div className={props.enableDensity ? '' : 'pointer-events-none select-none'}>
|
||||
<div className={props.settings.enableDensity ? '' : 'pointer-events-none select-none'}>
|
||||
<DeferredNumberInput
|
||||
className="w-full border rounded p-1 text-xs dark:bg-gray-900 dark:border-gray-600 dark:text-gray-200"
|
||||
value={props.minDensity}
|
||||
onChange={(num: number) => props.setMinDensity(num)}
|
||||
value={props.settings.minDensity}
|
||||
onChange={(num: number) => props.applySettings({ minDensity: num })}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-500"><T>Drop <</T> {props.minDensity}</span>
|
||||
<span className="text-[10px] text-gray-500"><T>Drop <</T> {props.settings.minDensity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -238,63 +187,63 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-start mt-2">
|
||||
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title={translate("If enabled, it ignores ALL density, elevation, geometry overlap, and custom filters. Will push all raw items")}>
|
||||
<input type="checkbox" checked={props.bypassFilters} onChange={e => props.setBypassFilters(e.target.checked)} className="mr-1" />
|
||||
<input type="checkbox" checked={props.settings.bypassFilters} onChange={e => props.applySettings({ bypassFilters: e.target.checked })} className="mr-1" />
|
||||
<T>Bypass All Filters</T>
|
||||
</label>
|
||||
<label className="flex items-center text-[10px] text-gray-700 dark:text-gray-300 cursor-pointer" title={translate("If a region is physically missing GHS raster data, allow it to pass instead of auto-failing")}>
|
||||
<input type="checkbox" checked={props.allowMissingGhs} onChange={e => props.setAllowMissingGhs(e.target.checked)} className="mr-1" />
|
||||
<input type="checkbox" checked={props.settings.allowMissingGhs} onChange={e => props.applySettings({ allowMissingGhs: e.target.checked })} className="mr-1" />
|
||||
<T>Allow Missing Data Gaps</T>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => props.setGhsFilterMode(props.ghsFilterMode === 'AND' ? 'OR' : 'AND')}>
|
||||
<span className={`text-[10px] font-semibold ${props.ghsFilterMode === 'AND' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>AND</span>
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => props.applySettings({ ghsFilterMode: props.settings.ghsFilterMode === 'AND' ? 'OR' : 'AND' })}>
|
||||
<span className={`text-[10px] font-semibold ${props.settings.ghsFilterMode === 'AND' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>AND</span>
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex h-4 w-8 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none"
|
||||
>
|
||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${props.ghsFilterMode === 'OR' ? 'translate-x-4' : 'translate-x-1'}`} />
|
||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${props.settings.ghsFilterMode === 'OR' ? 'translate-x-4' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<span className={`text-[10px] font-semibold ${props.ghsFilterMode === 'OR' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>OR</span>
|
||||
<span className={`text-[10px] font-semibold ${props.settings.ghsFilterMode === 'OR' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>OR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`col-span-2 ${props.enableGhsPop ? '' : 'opacity-50'}`}>
|
||||
<div className={`col-span-2 ${props.settings.enableGhsPop ? '' : 'opacity-50'}`}>
|
||||
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
|
||||
<span className="flex items-center">
|
||||
<input type="checkbox" checked={props.enableGhsPop} onChange={(e) => props.setEnableGhsPop(e.target.checked)} className="mr-2" />
|
||||
<input type="checkbox" checked={props.settings.enableGhsPop} onChange={(e) => props.applySettings({ enableGhsPop: e.target.checked })} className="mr-2" />
|
||||
<T>Min GHS Population</T>
|
||||
</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.minGhsPop.toLocaleString()}</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.settings.minGhsPop.toLocaleString()}</span>
|
||||
</label>
|
||||
<div className={`flex gap-2 items-center ${props.enableGhsPop ? '' : 'pointer-events-none select-none'}`}>
|
||||
<div className={`flex gap-2 items-center ${props.settings.enableGhsPop ? '' : 'pointer-events-none select-none'}`}>
|
||||
<span className="text-[10px] text-gray-400">{props.ghsBounds.minPop > 0 ? props.ghsBounds.minPop.toLocaleString() : '0'}</span>
|
||||
<DeferredRangeSlider
|
||||
min={Math.floor(props.ghsBounds.minPop)}
|
||||
max={Math.ceil(props.ghsBounds.maxPop)}
|
||||
step={parseInt(((props.ghsBounds.maxPop - props.ghsBounds.minPop) / 100).toFixed(0)) || 1}
|
||||
value={props.minGhsPop}
|
||||
onChange={(num: number) => props.setMinGhsPop(num)}
|
||||
value={props.settings.minGhsPop}
|
||||
onChange={(num: number) => props.applySettings({ minGhsPop: num })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400">{props.ghsBounds.maxPop > 0 ? props.ghsBounds.maxPop.toLocaleString() : translate('Max')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`col-span-2 ${props.enableGhsBuilt ? '' : 'opacity-50'}`}>
|
||||
<div className={`col-span-2 ${props.settings.enableGhsBuilt ? '' : 'opacity-50'}`}>
|
||||
<label className="flex items-center justify-between text-xs text-gray-700 dark:text-gray-300 mb-1 cursor-pointer">
|
||||
<span className="flex items-center">
|
||||
<input type="checkbox" checked={props.enableGhsBuilt} onChange={(e) => props.setEnableGhsBuilt(e.target.checked)} className="mr-2" />
|
||||
<input type="checkbox" checked={props.settings.enableGhsBuilt} onChange={(e) => props.applySettings({ enableGhsBuilt: e.target.checked })} className="mr-2" />
|
||||
<T>Min GHS Built Area</T>
|
||||
</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.minGhsBuilt.toLocaleString()}</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{props.settings.minGhsBuilt.toLocaleString()}</span>
|
||||
</label>
|
||||
<div className={`flex gap-2 items-center ${props.enableGhsBuilt ? '' : 'pointer-events-none select-none'}`}>
|
||||
<div className={`flex gap-2 items-center ${props.settings.enableGhsBuilt ? '' : 'pointer-events-none select-none'}`}>
|
||||
<span className="text-[10px] text-gray-400">{props.ghsBounds.minBuilt > 0 ? props.ghsBounds.minBuilt.toLocaleString() : '0'}</span>
|
||||
<DeferredRangeSlider
|
||||
min={Math.floor(props.ghsBounds.minBuilt)}
|
||||
max={Math.ceil(props.ghsBounds.maxBuilt)}
|
||||
step={parseInt(((props.ghsBounds.maxBuilt - props.ghsBounds.minBuilt) / 100).toFixed(0)) || 1}
|
||||
value={props.minGhsBuilt}
|
||||
onChange={(num: number) => props.setMinGhsBuilt(num)}
|
||||
value={props.settings.minGhsBuilt}
|
||||
onChange={(num: number) => props.applySettings({ minGhsBuilt: num })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400">{props.ghsBounds.maxBuilt > 0 ? props.ghsBounds.maxBuilt.toLocaleString() : translate('Max')}</span>
|
||||
@ -303,26 +252,26 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
</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.gridMode === 'admin' ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<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.cellSize}
|
||||
onChange={(num: number) => props.setCellSize(num)}
|
||||
value={props.settings.cellSize}
|
||||
onChange={(num: number) => props.applySettings({ cellSize: num })}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
disabled={props.gridMode === 'admin'}
|
||||
disabled={props.settings.gridMode === 'admin'}
|
||||
/>
|
||||
</div>
|
||||
{props.gridMode === 'centers' ? (
|
||||
{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>
|
||||
<DeferredRangeSlider
|
||||
className="w-full"
|
||||
value={props.centroidOverlap}
|
||||
onChange={(num: number) => props.setCentroidOverlap(num)}
|
||||
value={props.settings.centroidOverlap}
|
||||
onChange={(num: number) => props.applySettings({ centroidOverlap: num })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@ -338,13 +287,13 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"><T>Overlap %</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.cellOverlap}
|
||||
onChange={(num: number) => props.setCellOverlap(num)}
|
||||
value={props.settings.cellOverlap}
|
||||
onChange={(num: number) => props.applySettings({ cellOverlap: num })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
title={translate("Increase to balloon the geometric cells and ensure no places are missed at the seams.")}
|
||||
disabled={props.gridMode === 'admin'}
|
||||
disabled={props.settings.gridMode === 'admin'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -353,10 +302,10 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
|
||||
<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.maxCellsLimit}
|
||||
onChange={(num: number) => props.setMaxCellsLimit(num)}
|
||||
value={props.settings.maxCellsLimit}
|
||||
onChange={(num: number) => props.applySettings({ maxCellsLimit: num })}
|
||||
step={500}
|
||||
disabled={props.gridMode === 'admin'}
|
||||
disabled={props.settings.gridMode === 'admin'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,53 +44,51 @@ export function useGridSimulatorState({
|
||||
const [skippedTotal, setSkippedTotal] = useState(0);
|
||||
|
||||
// Config
|
||||
const [gridMode, setGridMode] = useLocalStorage<'hex' | 'square' | 'admin' | 'centers'>('pm_gridMode', 'hex');
|
||||
const [pathOrder, setPathOrder] = useLocalStorage<'zigzag' | 'snake' | 'spiral-out' | 'spiral-in' | 'shortest'>('pm_pathOrder', 'snake');
|
||||
const [groupByRegion, setGroupByRegion] = useLocalStorage<boolean>('pm_groupByRegion', true);
|
||||
const [cellSize, setCellSize] = useLocalStorage<number>('pm_cellSize', 2.5);
|
||||
const [cellOverlap, setCellOverlap] = useLocalStorage<number>('pm_cellOverlap', 0);
|
||||
const [centroidOverlap, setCentroidOverlap] = useLocalStorage<number>('pm_centroidOverlap', 50);
|
||||
const [ghsFilterMode, setGhsFilterMode] = useLocalStorage<'AND' | 'OR'>('pm_ghsFilterMode', 'AND');
|
||||
const [maxCellsLimit, setMaxCellsLimit] = useLocalStorage<number>('pm_maxCellsLimit', 15000);
|
||||
const [maxElevation, setMaxElevation] = useLocalStorage<number>('pm_maxElevation', 700);
|
||||
const [minDensity, setMinDensity] = useLocalStorage<number>('pm_minDensity', 10);
|
||||
const [minGhsPop, setMinGhsPop] = useLocalStorage<number>('pm_minGhsPop', 0);
|
||||
const [minGhsBuilt, setMinGhsBuilt] = useLocalStorage<number>('pm_minGhsBuilt', 0);
|
||||
const [enableElevation, setEnableElevation] = useLocalStorage<boolean>('pm_enElev', false);
|
||||
const [enableDensity, setEnableDensity] = useLocalStorage<boolean>('pm_enDens', false);
|
||||
const [enableGhsPop, setEnableGhsPop] = useLocalStorage<boolean>('pm_enPop', false);
|
||||
const [enableGhsBuilt, setEnableGhsBuilt] = useLocalStorage<boolean>('pm_enBuilt', false);
|
||||
const [allowMissingGhs, setAllowMissingGhs] = useLocalStorage<boolean>('pm_allowMissGhs', false);
|
||||
const [bypassFilters, setBypassFilters] = useLocalStorage<boolean>('pm_bypassFilters', false);
|
||||
const defaultSettings: GridSimulatorSettings = {
|
||||
gridMode: 'hex',
|
||||
pathOrder: 'snake',
|
||||
groupByRegion: true,
|
||||
cellSize: 2.5,
|
||||
cellOverlap: 0,
|
||||
centroidOverlap: 50,
|
||||
ghsFilterMode: 'AND',
|
||||
maxCellsLimit: 15000,
|
||||
maxElevation: 700,
|
||||
minDensity: 10,
|
||||
minGhsPop: 0,
|
||||
minGhsBuilt: 0,
|
||||
enableElevation: false,
|
||||
enableDensity: false,
|
||||
enableGhsPop: false,
|
||||
enableGhsBuilt: false,
|
||||
allowMissingGhs: false,
|
||||
bypassFilters: false
|
||||
};
|
||||
|
||||
const [settings, setSettings] = useLocalStorage<GridSimulatorSettings>('pm_gridSettings', defaultSettings);
|
||||
|
||||
const {
|
||||
gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap,
|
||||
ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt,
|
||||
enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters
|
||||
} = settings;
|
||||
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [calcStats, setCalcStats] = useState({ current: 0, total: 0, valid: 0 });
|
||||
const skippedCellsRef = useRef<SimulatorGridCell[]>([]);
|
||||
|
||||
const applySettings = useCallback((updates: Partial<GridSimulatorSettings>) => {
|
||||
if (!updates) return;
|
||||
setSettings(prev => ({ ...prev, ...updates }));
|
||||
}, [setSettings]);
|
||||
|
||||
// Apply restored settings once on mount
|
||||
const appliedInitialRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || appliedInitialRef.current) return;
|
||||
appliedInitialRef.current = true;
|
||||
if (initialSettings.gridMode) setGridMode(initialSettings.gridMode);
|
||||
if (initialSettings.pathOrder) setPathOrder(initialSettings.pathOrder);
|
||||
if (initialSettings.groupByRegion !== undefined) setGroupByRegion(initialSettings.groupByRegion);
|
||||
if (initialSettings.cellSize) setCellSize(initialSettings.cellSize);
|
||||
if (initialSettings.cellOverlap !== undefined) setCellOverlap(initialSettings.cellOverlap);
|
||||
if (initialSettings.centroidOverlap !== undefined) setCentroidOverlap(initialSettings.centroidOverlap);
|
||||
if (initialSettings.ghsFilterMode) setGhsFilterMode(initialSettings.ghsFilterMode);
|
||||
if (initialSettings.maxCellsLimit) setMaxCellsLimit(initialSettings.maxCellsLimit);
|
||||
if (initialSettings.maxElevation !== undefined) setMaxElevation(initialSettings.maxElevation);
|
||||
if (initialSettings.minDensity !== undefined) setMinDensity(initialSettings.minDensity);
|
||||
if (initialSettings.minGhsPop !== undefined) setMinGhsPop(initialSettings.minGhsPop);
|
||||
if (initialSettings.minGhsBuilt !== undefined) setMinGhsBuilt(initialSettings.minGhsBuilt);
|
||||
if (initialSettings.enableElevation !== undefined) setEnableElevation(initialSettings.enableElevation);
|
||||
if (initialSettings.enableDensity !== undefined) setEnableDensity(initialSettings.enableDensity);
|
||||
if (initialSettings.enableGhsPop !== undefined) setEnableGhsPop(initialSettings.enableGhsPop);
|
||||
if (initialSettings.enableGhsBuilt !== undefined) setEnableGhsBuilt(initialSettings.enableGhsBuilt);
|
||||
if (initialSettings.allowMissingGhs !== undefined) setAllowMissingGhs(initialSettings.allowMissingGhs);
|
||||
if (initialSettings.bypassFilters !== undefined) setBypassFilters(initialSettings.bypassFilters);
|
||||
}, [initialSettings]);
|
||||
applySettings(initialSettings);
|
||||
}, [initialSettings, applySettings]);
|
||||
|
||||
const stopRequestedRef = useRef<boolean>(false);
|
||||
const reqRef = useRef<number>();
|
||||
@ -376,24 +374,8 @@ export function useGridSimulatorState({
|
||||
ghsBounds,
|
||||
|
||||
// Configuration actions
|
||||
setGridMode,
|
||||
setPathOrder,
|
||||
setGroupByRegion,
|
||||
setCellSize,
|
||||
setCellOverlap,
|
||||
setCentroidOverlap,
|
||||
setGhsFilterMode,
|
||||
setMaxCellsLimit,
|
||||
setMaxElevation,
|
||||
setMinDensity,
|
||||
setMinGhsPop,
|
||||
setMinGhsBuilt,
|
||||
setEnableElevation,
|
||||
setEnableDensity,
|
||||
setEnableGhsPop,
|
||||
setEnableGhsBuilt,
|
||||
setAllowMissingGhs,
|
||||
setBypassFilters,
|
||||
settings,
|
||||
applySettings,
|
||||
|
||||
// Core Actions
|
||||
setIsPlaying,
|
||||
@ -403,10 +385,5 @@ export function useGridSimulatorState({
|
||||
handleClear,
|
||||
getFinalHopList,
|
||||
getCurrentSettings,
|
||||
|
||||
// Config variables
|
||||
gridMode, pathOrder, groupByRegion, cellSize, cellOverlap, centroidOverlap,
|
||||
ghsFilterMode, maxCellsLimit, maxElevation, minDensity, minGhsPop, minGhsBuilt,
|
||||
enableElevation, enableDensity, enableGhsPop, enableGhsBuilt, allowMissingGhs, bypassFilters
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||
export interface MapLocation {
|
||||
@ -75,7 +75,27 @@ const AnalyticsMap: React.FC<AnalyticsMapProps> = ({ locations, className }) =>
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const markersRef = useRef<maplibregl.Marker[]>([]);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getResolvedTheme = useCallback(() => {
|
||||
if (theme === 'system' && typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}, [theme]);
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<'dark' | 'light'>(getResolvedTheme());
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => setResolvedTheme(getResolvedTheme());
|
||||
updateTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", updateTheme);
|
||||
return () => mediaQuery.removeEventListener("change", updateTheme);
|
||||
}
|
||||
}, [theme, getResolvedTheme]);
|
||||
|
||||
const style = useMemo(() => resolvedTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light, [resolvedTheme]);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user