From 784bd4d903b9f898ff9d29e772ec49a663d43c7b Mon Sep 17 00:00:00 2001 From: Babayaga Date: Mon, 23 Mar 2026 14:15:28 +0100 Subject: [PATCH] Maintenance Love :) --- .../src/modules/places/CompetitorsMapView.tsx | 291 ++++++++---------- .../src/modules/places/client-gridsearch.ts | 10 + .../places/components/GridSearchMap.tsx | 30 +- .../modules/places/gadm-picker/GadmPicker.tsx | 123 +++----- .../components/GadmSearchControls.tsx | 107 +++++++ .../gridsearch/CompetitorsReportView.tsx | 94 ++++++ .../modules/places/gridsearch/GridSearch.tsx | 39 ++- .../places/gridsearch/GridSearchResults.tsx | 36 ++- .../modules/places/gridsearch/JobViewer.tsx | 163 +++++----- .../simulator/GridSearchSimulator.tsx | 81 +++-- .../components/SimulatorSettingsPanel.tsx | 183 ++++------- .../simulator/hooks/useGridSimulatorState.ts | 97 +++--- .../ui/src/pages/analytics/AnalyticsMap.tsx | 24 +- 13 files changed, 729 insertions(+), 549 deletions(-) create mode 100644 packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx create mode 100644 packages/ui/src/modules/places/gridsearch/CompetitorsReportView.tsx diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index e53bcebb..91c822f2 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -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 = ({ competit const mapContainer = useRef(null); const mapWrapper = useRef(null); const map = useRef(null); - const { theme, systemTheme } = useTheme(); - const currentTheme = theme === 'system' ? systemTheme : theme; - const [mapStyleKey, setMapStyleKey] = useState(currentTheme === 'dark' ? 'dark' : 'light'); + const { theme } = useTheme(); + const [mapStyleKey, setMapStyleKey] = useState('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(null); const [gadmPickerActive, setGadmPickerActive] = useState(false); - + // Grid Search Simulator State const [simulatorActive, setSimulatorActive] = useState(false); const [pickerRegions, setPickerRegions] = useState([]); @@ -138,7 +148,7 @@ export const CompetitorsMapView: React.FC = ({ competit const [simulatorData, setSimulatorData] = useState(null); const [simulatorPath, setSimulatorPath] = useState(null); const [simulatorScanner, setSimulatorScanner] = useState(null); - + // Layer Toggles const [showDensity, setShowDensity] = useState(false); const [showCenters, setShowCenters] = useState(false); @@ -299,7 +309,7 @@ export const CompetitorsMapView: React.FC = ({ 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 = ({ competit }); } } - }, [selectedLocation, sidebarWidth]); + }, [selectedLocation, sidebarWidth, gadmPickerActive, simulatorActive]); // Resizing Handlers const startResizing = useCallback(() => setIsResizing(true), []); @@ -348,82 +358,138 @@ export const CompetitorsMapView: React.FC = ({ competit return ( -
+
{/* Split View Container */} -
+
- {/* Map Panel */} - {/* Map Panel */} -
+ {/* Left Tools Sidebar */} + {(gadmPickerActive || simulatorActive) && ( +
+ { + setGadmPickerActive(val === 'gadm'); + setSimulatorActive(val === 'simulator'); + }} + className="flex flex-col h-full w-full" + > +
+ + Area Selector + Grid Search + + +
+ + + setGadmPickerActive(false)} + onSelectionChange={(r, p) => { + setPickerRegions(r); + setPickerPolygons(p); + }} + initialRegions={initialGadmRegions} + /> + + + + {(pickerRegions && pickerRegions.length > 0) ? ( + true} + initialSettings={initialSimulatorSettings} + /> + ) : ( +
+ Please select a region using the Area Selector first to define the search bounds. +
+ )} +
+
+
+ )} + + {/* Map Panel & Center Layout */} +
{/* Header: Search Region */} -
-
- + }`} + title="Toggle Tools Sidebar" + > + {(gadmPickerActive || simulatorActive) ? : } + Tools + - {/* Grid Search Toggle */} - + {/* Info Button */} + +
+ +
- {/* Info Button */} - +
{ /* Spacer */}
-
{ /* Spacer */} +
+
+
{/* Map Viewport */} -
+
- + {map.current && ( <> - - = ({ competit /> )} - -
- setGadmPickerActive(false)} - onSelectionChange={(r, p) => { - setPickerRegions(r); - setPickerPolygons(p); - }} - initialRegions={initialGadmRegions} - /> -
- - {simulatorActive && ( -
-
-

Grid Search Simulator

- -
-
- {(pickerRegions && pickerRegions.length > 0) ? ( - true} - initialSettings={initialSimulatorSettings} - /> - ) : ( -
- Please select a region using the Area Selector first to define the search bounds. -
- )} -
-
- )}
{/* Footer: Status Info & Toggles */} @@ -514,7 +541,6 @@ export const CompetitorsMapView: React.FC = ({ competit
- {/* Resizable Handle and Property Pane */} {/* Resizable Handle and Property Pane */} {(selectedLocation || infoPanelOpen) && ( <> @@ -546,57 +572,6 @@ export const CompetitorsMapView: React.FC = ({ competit )}
- - {/* Legend / Location List */} -
-

- - Locations ({validLocations.length}) -

- - {Object.keys(locationsByCity).length > 0 ? ( - Object.entries(locationsByCity).map(([city, locations]) => ( -
-
-

- {city} ({locations.length}) -

-
-
-
- {locations.map((loc) => ( -
{ - if (map.current) { - map.current.flyTo({ center: [loc.lon, loc.lat], zoom: 15 }); - } - setSelectedLocation(loc); - mapContainer.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }}> - - {loc.label} - -
-
{loc.title}
-
- {loc.types?.slice(0, 2).map(t => ( - - {t.replace(/_/g, ' ')} - - ))} -
-

{loc.address}

-
-
- ))} -
-
-
- )) - ) : ( -
Loading locations...
- )} -
); }; diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index e93e9a6c..ce869fe6 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -188,3 +188,13 @@ export const fetchCompetitorById = async (id: string): Promise => { const json = await res.json(); return json.data; }; + +export const fetchGridSearchExport = async (jobId: string, format: 'md' | 'json' = 'md'): Promise => { + 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(); +}; diff --git a/packages/ui/src/modules/places/components/GridSearchMap.tsx b/packages/ui/src/modules/places/components/GridSearchMap.tsx index 038fdd48..5cc387ac 100644 --- a/packages/ui/src/modules/places/components/GridSearchMap.tsx +++ b/packages/ui/src/modules/places/components/GridSearchMap.tsx @@ -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(() => { 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; diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index 404303a7..38c11757 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -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(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('pm_gadm_levelOption', 0); const [resolutionOption, setResolutionOption] = useLocalStorage('pm_gadm_resolutionOption', 1); - const [enrich, setEnrich] = useLocalStorage('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(null); + const headerSuggestionsWrapRef = useRef(null); + const [headerPortalTarget, setHeaderPortalTarget] = useState(null); + + useEffect(() => { + setHeaderPortalTarget(document.getElementById('gadm-controls-portal')); + }, []); const [selectedRegions, setSelectedRegions] = useState([]); const [geojsons, setGeojsons] = useState>({}); @@ -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 ? (

GADM Area Selector

@@ -574,78 +582,16 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className

{/* Config & Search */} -
-
-
- - -
-
- - -
-
- -
- - -
- -
-
- - 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 &&
} -
- - {showSuggestions && suggestions.length > 0 && ( -
    - {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 ( -
  • { e.preventDefault(); handleSelectRegion(s, undefined, e.ctrlKey || e.metaKey); }} - > -
    {primaryName || "Unknown"}
    -
    {subText ? `${subText} • ` : ''}{gidKey && s[gidKey] ? s[gidKey] : ''}
    -
  • - ); - })} -
- )} -
-
+ handleSelectRegion(s, undefined, ctrlKey)} + suggestionsWrapRef={suggestionsWrapRef} + layout="vertical" + />
{/* Visual Inspector Panel */} @@ -766,5 +712,26 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className )}
+ ) : null; + + return ( + <> + {sidebarContent} + + {/* Portal Config & Search to Header */} + {headerPortalTarget && createPortal( + handleSelectRegion(s, undefined, ctrlKey)} + suggestionsWrapRef={headerSuggestionsWrapRef} + layout="horizontal" + />, + headerPortalTarget + )} + ); } diff --git a/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx b/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx new file mode 100644 index 00000000..0e2df993 --- /dev/null +++ b/packages/ui/src/modules/places/gadm-picker/components/GadmSearchControls.tsx @@ -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; + 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) => ( +
+ {label} +
+ {LEVEL_OPTIONS.map(opt => ( + + ))} +
+
+ ); + + return ( +
+ {/* Search Input */} +
+
+ + 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 &&
} +
+ + {showSuggestions && suggestions.length > 0 && ( +
    + {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 ( +
  • { e.preventDefault(); onSelectSuggestion(s, e.ctrlKey || e.metaKey); }} + > +
    {primaryName || "Unknown"}
    +
    {subText ? `${subText} • ` : ''}{gidKey && s[gidKey] ? s[gidKey] : ''}
    +
  • + ); + })} +
+ )} +
+ + {/* Level Selectors */} +
+ {renderSegments(levelOption, setLevelOption, 'Search')} + {renderSegments(resolutionOption, setResolutionOption, 'Resolve')} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/gridsearch/CompetitorsReportView.tsx b/packages/ui/src/modules/places/gridsearch/CompetitorsReportView.tsx new file mode 100644 index 00000000..c9b1c652 --- /dev/null +++ b/packages/ui/src/modules/places/gridsearch/CompetitorsReportView.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +

Generating report...

+
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to load report

+

{error}

+
+
+
+ ); + } + + return ( +
+
+

Grid Search Report

+ +
+
+ {markdown ? ( + + ) : ( +

No report content available.

+ )} +
+
+ ); +} diff --git a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx index 21028acc..0dea7313 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx @@ -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 */}
+ {/* Main Content Area */} -
+
{selectedJobId ? ( ) : ( diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 900bad2b..528a1ed7 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -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(() => { 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 (
-
+
{competitors.length} results @@ -99,18 +100,25 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc > +
-
+
{viewMode === 'grid' && ( {})} - onOpenSettings={() => {}} + updateExcludedTypes={updateExcludedTypes || (async () => { })} + onOpenSettings={() => { }} showExcluded={showExcluded} setShowExcluded={setShowExcluded} enrich={dummyEnrich} @@ -123,7 +131,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc {}} + toggleFilter={() => { }} /> )} @@ -162,9 +170,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc {})} + updateExcludedTypes={updateExcludedTypes || (async () => { })} /> )} + + {viewMode === 'report' && ( + + )}
); diff --git a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx index 526191c7..ef58f5ce 100644 --- a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx +++ b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx @@ -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 ( -
+

Loading search results...

@@ -73,7 +74,7 @@ export function JobViewer({ jobId }: { jobId: string }) { if (error) { return ( -
+
@@ -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 ( -
-
-
+
+
+
-
+

Search Results

-
-

Job ID: {jobId}

- {jobData && ( - <> - {jobData.request?.search?.types && ( -

Queries: {jobData.request.search.types.join(', ')}

- )} - {(jobData.query?.region || jobData.request?.enumerate?.region) && ( -

Region: {jobData.query?.region || jobData.request?.enumerate?.region}

- )} - {(jobData.query?.level || jobData.request?.enumerate?.level) && ( -

Level: {jobData.query?.level || jobData.request?.enumerate?.level}

- )} - {jobData.areas && ( -

Areas Scanned: {jobData.areas.length}

- )} - {jobData.generatedAt && ( -

Generated At: {new Date(jobData.generatedAt).toLocaleString()}

- )} - + + +
+

Job ID: {jobId}

+ {jobData && ( + <> + {jobData.request?.search?.types && ( +

Queries: {jobData.request.search.types.join(', ')}

+ )} + {(jobData.query?.region || jobData.request?.enumerate?.region) && ( +

Region: {jobData.query?.region || jobData.request?.enumerate?.region}

+ )} + {(jobData.query?.level || jobData.request?.enumerate?.level) && ( +

Level: {jobData.query?.level || jobData.request?.enumerate?.level}

+ )} + {jobData.areas && ( +

Areas Scanned: {jobData.areas.length}

+ )} + {jobData.generatedAt && ( +

Generated At: {new Date(jobData.generatedAt).toLocaleString()}

+ )} + {foundTypes.length > 0 && ( +
+ Found Types: + {foundTypes.map(t => ( + + {t} + + ))} +
+ )} + + )} +
+ + {/* Restored GADM Areas */} + {guidedAreas.length > 0 && ( +
+
+ + + GADM Areas ({guidedAreas.length}) + + {isLocked && ( + + Locked + + )} + {isComplete && ( + + Complete + + )} +
+
+ {guidedAreas.map((area: any) => ( + + + {area.name} + L{area.level} + + ))} +
+
)} -
- {/* Restored GADM Areas */} - {guidedAreas.length > 0 && ( -
-
- - - GADM Areas ({guidedAreas.length}) - - {isLocked && ( - - Locked - - )} - {isComplete && ( - - Complete - - )} + {/* Search Settings Summary */} + {searchSettings && ( +
+ {searchSettings.cellSize && Cell: {searchSettings.cellSize}km} + {searchSettings.minPopDensity > 0 && Min Pop: {searchSettings.minPopDensity}} + {searchSettings.pathOrder && Path: {searchSettings.pathOrder}} + {searchSettings.gridMode && Grid: {searchSettings.gridMode}}
-
- {guidedAreas.map((area: any) => ( - - - {area.name} - L{area.level} - - ))} -
-
- )} - - {/* Search Settings Summary */} - {searchSettings && ( -
- {searchSettings.cellSize && Cell: {searchSettings.cellSize}km} - {searchSettings.minPopDensity > 0 && Min Pop: {searchSettings.minPopDensity}} - {searchSettings.pathOrder && Path: {searchSettings.pathOrder}} - {searchSettings.gridMode && Grid: {searchSettings.gridMode}} -
- )} + )} +
diff --git a/packages/ui/src/modules/places/gridsearch/simulator/GridSearchSimulator.tsx b/packages/ui/src/modules/places/gridsearch/simulator/GridSearchSimulator.tsx index 7a07b859..3cf94e72 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/GridSearchSimulator.tsx +++ b/packages/ui/src/modules/places/gridsearch/simulator/GridSearchSimulator.tsx @@ -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(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 (
-
+ {portalTarget && createPortal( - -
- - - - {state.isCalculating && ( -
-
-
- - - Calculating... - {state.calcStats.total > 0 && {state.calcStats.current} / {state.calcStats.total} cells} - -
- + />, + portalTarget + )} + {hasRegions && ( + <> +
+
-
+ + + + {state.isCalculating && ( +
+
+
+ + + Calculating... + {state.calcStats.total > 0 && {state.calcStats.current} / {state.calcStats.total} cells} + +
+ +
+
+ )} + )}
); diff --git a/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx b/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx index 6e37d37a..f6111c90 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx +++ b/packages/ui/src/modules/places/gridsearch/simulator/components/SimulatorSettingsPanel.tsx @@ -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) => 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) {
@@ -160,32 +109,32 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
@@ -194,40 +143,40 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
-
+
-
+
props.setMaxElevation(num)} + value={props.settings.maxElevation} + onChange={(num: number) => props.applySettings({ maxElevation: num })} /> - Drop > {props.maxElevation}m + Drop > {props.settings.maxElevation}m
-
+
-
+
props.setMinDensity(num)} + value={props.settings.minDensity} + onChange={(num: number) => props.applySettings({ minDensity: num })} /> - Drop < {props.minDensity} + Drop < {props.settings.minDensity}
@@ -238,63 +187,63 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
-
props.setGhsFilterMode(props.ghsFilterMode === 'AND' ? 'OR' : 'AND')}> - AND +
props.applySettings({ ghsFilterMode: props.settings.ghsFilterMode === 'AND' ? 'OR' : 'AND' })}> + AND - OR + OR
-
+
-
+
{props.ghsBounds.minPop > 0 ? props.ghsBounds.minPop.toLocaleString() : '0'} props.setMinGhsPop(num)} + value={props.settings.minGhsPop} + onChange={(num: number) => props.applySettings({ minGhsPop: num })} className="flex-1" /> {props.ghsBounds.maxPop > 0 ? props.ghsBounds.maxPop.toLocaleString() : translate('Max')}
-
+
-
+
{props.ghsBounds.minBuilt > 0 ? props.ghsBounds.minBuilt.toLocaleString() : '0'} props.setMinGhsBuilt(num)} + value={props.settings.minGhsBuilt} + onChange={(num: number) => props.applySettings({ minGhsBuilt: num })} className="flex-1" /> {props.ghsBounds.maxBuilt > 0 ? props.ghsBounds.maxBuilt.toLocaleString() : translate('Max')} @@ -303,26 +252,26 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) {
-
+
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'} />
- {props.gridMode === 'centers' ? ( + {props.settings.gridMode === 'centers' ? (
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) { 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'} />
)} @@ -353,10 +302,10 @@ export function SimulatorSettingsPanel(props: SimulatorSettingsPanelProps) { 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'} />
diff --git a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts index 8a88c1ad..d6830579 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts @@ -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('pm_groupByRegion', true); - const [cellSize, setCellSize] = useLocalStorage('pm_cellSize', 2.5); - const [cellOverlap, setCellOverlap] = useLocalStorage('pm_cellOverlap', 0); - const [centroidOverlap, setCentroidOverlap] = useLocalStorage('pm_centroidOverlap', 50); - const [ghsFilterMode, setGhsFilterMode] = useLocalStorage<'AND' | 'OR'>('pm_ghsFilterMode', 'AND'); - const [maxCellsLimit, setMaxCellsLimit] = useLocalStorage('pm_maxCellsLimit', 15000); - const [maxElevation, setMaxElevation] = useLocalStorage('pm_maxElevation', 700); - const [minDensity, setMinDensity] = useLocalStorage('pm_minDensity', 10); - const [minGhsPop, setMinGhsPop] = useLocalStorage('pm_minGhsPop', 0); - const [minGhsBuilt, setMinGhsBuilt] = useLocalStorage('pm_minGhsBuilt', 0); - const [enableElevation, setEnableElevation] = useLocalStorage('pm_enElev', false); - const [enableDensity, setEnableDensity] = useLocalStorage('pm_enDens', false); - const [enableGhsPop, setEnableGhsPop] = useLocalStorage('pm_enPop', false); - const [enableGhsBuilt, setEnableGhsBuilt] = useLocalStorage('pm_enBuilt', false); - const [allowMissingGhs, setAllowMissingGhs] = useLocalStorage('pm_allowMissGhs', false); - const [bypassFilters, setBypassFilters] = useLocalStorage('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('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([]); + const applySettings = useCallback((updates: Partial) => { + 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(false); const reqRef = useRef(); @@ -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 }; } diff --git a/packages/ui/src/pages/analytics/AnalyticsMap.tsx b/packages/ui/src/pages/analytics/AnalyticsMap.tsx index 5e37de5f..67e0c66c 100644 --- a/packages/ui/src/pages/analytics/AnalyticsMap.tsx +++ b/packages/ui/src/pages/analytics/AnalyticsMap.tsx @@ -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 = ({ locations, className }) => const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); - 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]);