From ac7d888f181016b73a45a99c496b879615e493fa Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 31 Mar 2026 10:56:00 +0200 Subject: [PATCH] map & uds bugs --- .../ui/src/components/MarkdownRenderer.tsx | 28 +- .../ui/src/components/ResponsiveImage.tsx | 16 +- packages/ui/src/components/TopNavigation.tsx | 6 + .../modules/places/CompetitorsGridView.tsx | 98 +++---- .../src/modules/places/CompetitorsMapView.tsx | 106 ++++--- packages/ui/src/modules/places/InfoPanel.tsx | 18 +- .../ui/src/modules/places/LocationDetail.tsx | 4 +- .../modules/places/components/MapFooter.tsx | 41 ++- .../map-layers/LiveSearchLayers.tsx | 28 +- .../components/map-layers/LocationLayers.tsx | 28 +- .../components/map-layers/RegionLayers.tsx | 10 +- .../components/map-layers/SimulatorLayers.tsx | 18 +- .../modules/places/gadm-picker/GadmPicker.tsx | 34 ++- .../gadm-picker/GadmRegionCollector.tsx | 93 +++--- .../places/gadm-picker/GadmTreePicker.tsx | 11 +- .../modules/places/gridsearch/GridSearch.tsx | 22 +- .../places/gridsearch/GridSearchResults.tsx | 271 ++++++++++++------ .../places/gridsearch/GridSearchWizard.tsx | 38 ++- .../modules/places/gridsearch/JobViewer.tsx | 173 +++++------ .../modules/places/hooks/useMapControls.ts | 44 +-- .../ui/src/modules/places/useGridColumns.tsx | 55 ++-- 21 files changed, 687 insertions(+), 455 deletions(-) diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx index bf939631..99f07766 100644 --- a/packages/ui/src/components/MarkdownRenderer.tsx +++ b/packages/ui/src/components/MarkdownRenderer.tsx @@ -294,13 +294,14 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU return

{children}

; }, p: ({ node, children, ...props }) => { - // Check if the paragraph contains an image - // @ts-ignore - const hasImage = node?.children?.some((child: any) => - child.type === 'element' && child.tagName === 'img' - ); + // Deep check if any child is an image to avoid

nesting issues + const hasImage = (n: any): boolean => { + if (n.type === 'element' && n.tagName === 'img') return true; + if (n.children) return n.children.some(hasImage); + return false; + }; - if (hasImage) { + if (hasImage(node)) { return

{children}
; } return

{children}

; @@ -335,7 +336,20 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU if (ids.length > 0) { return ( }> - + ); } diff --git a/packages/ui/src/components/ResponsiveImage.tsx b/packages/ui/src/components/ResponsiveImage.tsx index f712353f..a221956a 100644 --- a/packages/ui/src/components/ResponsiveImage.tsx +++ b/packages/ui/src/components/ResponsiveImage.tsx @@ -51,7 +51,7 @@ const ResponsiveImage: React.FC = React.memo(({ // Lazy load logic const [isInView, setIsInView] = useState(props.loading === 'eager'); const [imgLoaded, setImgLoaded] = useState(false); - const ref = React.useRef(null); + const ref = React.useRef(null); const imgRef = React.useRef(null); @@ -118,7 +118,7 @@ const ResponsiveImage: React.FC = React.memo(({ if (!isInView || isLoadingOrPending) { // Use className for wrapper if provided, otherwise generic // We attach the ref here to detect when this placeholder comes into view - return
; + return ; } if (error || !data) { @@ -126,11 +126,11 @@ const ResponsiveImage: React.FC = React.memo(({ if (typeof src === 'string') { return {alt}; } - return
Failed to load image
; + return Failed to load image; } return ( -
+ {(data.sources || []).map((source, index) => ( @@ -158,11 +158,11 @@ const ResponsiveImage: React.FC = React.memo(({ /> {!imgLoaded && ( -
-
-
+ + + )} -
+
); }, (prev, next) => { // Only compare props that affect visual output — ignore callbacks diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx index 716faab0..4f2e134b 100644 --- a/packages/ui/src/components/TopNavigation.tsx +++ b/packages/ui/src/components/TopNavigation.tsx @@ -296,6 +296,12 @@ const TopNavigation = () => { Chat + + + + GridSearch + + )} diff --git a/packages/ui/src/modules/places/CompetitorsGridView.tsx b/packages/ui/src/modules/places/CompetitorsGridView.tsx index 6928189a..fbdb9d90 100644 --- a/packages/ui/src/modules/places/CompetitorsGridView.tsx +++ b/packages/ui/src/modules/places/CompetitorsGridView.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { type GridPreset, getPresetVisibilityModel } from './useGridColumns'; import { DataGrid, useGridApiRef, @@ -31,13 +32,17 @@ import { } from './gridUtils'; import { useGridColumns } from './useGridColumns'; import { GripVertical } from 'lucide-react'; -import { LocationDetailView } from './LocationDetail'; interface CompetitorsGridViewProps { competitors: CompetitorFull[]; loading: boolean; settings: CompetitorSettings; updateExcludedTypes: (types: string[]) => Promise; + selectedPlaceId?: string | null; + onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void; + isOwner?: boolean; + isPublic?: boolean; + preset?: GridPreset; } const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => { @@ -61,7 +66,17 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => { ); }; -export const CompetitorsGridView: React.FC = ({ competitors, loading, settings, updateExcludedTypes }) => { +export const CompetitorsGridView: React.FC = ({ + competitors, + loading, + settings, + updateExcludedTypes, + selectedPlaceId, + onSelectPlace, + isOwner = false, + isPublic = false, + preset = 'full' +}) => { const muiTheme = useMuiTheme(); const [searchParams, setSearchParams] = useSearchParams(); @@ -69,9 +84,14 @@ export const CompetitorsGridView: React.FC = ({ compet const [filterModel, setFilterModel] = useState(() => { const fromUrl = paramsToFilterModel(searchParams); if (fromUrl.items.length === 0 && !searchParams.has('nofilter')) { - return { - items: [{ field: 'email', operator: 'isNotEmpty' }] - }; + // Only apply default "valid leads" filter if we are NOT in a public stripped view + const shouldHideEmptyEmails = !isPublic || isOwner; + + if (shouldHideEmptyEmails) { + return { + items: [{ field: 'email', operator: 'isNotEmpty' }] + }; + } } return fromUrl; }); @@ -124,32 +144,29 @@ export const CompetitorsGridView: React.FC = ({ compet const [highlightedRowId, setHighlightedRowId] = useState(null); const [anchorRowId, setAnchorRowId] = useState(null); - // Sidebar panel state - const [showLocationDetail, setShowLocationDetail] = useState(false); - const [sidebarWidth, setSidebarWidth] = useState(400); + // Sync local highlighted state with global selectedPlaceId + useEffect(() => { + if (selectedPlaceId !== highlightedRowId) { + setHighlightedRowId(selectedPlaceId || null); + } + }, [selectedPlaceId]); - const isResizingRef = useRef(false); - const startResizing = useCallback(() => { - isResizingRef.current = true; - const onMove = (e: MouseEvent) => { - if (!isResizingRef.current) return; - const newWidth = window.innerWidth - e.clientX; - if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth); - }; - const onUp = () => { - isResizingRef.current = false; - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - }; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - }, []); + // Removed local sidebar panel state (moved to GridSearchResults) // Get Columns Definition const columns = useGridColumns({ settings, updateExcludedTypes }); + + // Sync Visibility Model when preset changes + useEffect(() => { + const presetModel = getPresetVisibilityModel(preset); + setColumnVisibilityModel(prev => ({ + ...prev, + ...presetModel + })); + }, [preset]); // Update URL when filter model changes const handleFilterModelChange = (newFilterModel: GridFilterModel) => { @@ -338,8 +355,7 @@ export const CompetitorsGridView: React.FC = ({ compet handled = true; } else if (e.key === 'Enter') { handled = true; - setShowLocationDetail(prev => !prev); - + onSelectPlace?.(highlightedRowId, 'toggle'); } else if (e.key === ' ') { handled = true; const currentId = String(rowIds[currentIdx]); @@ -364,6 +380,7 @@ export const CompetitorsGridView: React.FC = ({ compet if (nextIdx !== currentIdx || !highlightedRowId) { const nextId = String(rowIds[nextIdx]); setHighlightedRowId(nextId); + onSelectPlace?.(nextId, 'select'); if (e.shiftKey) { const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx; @@ -396,9 +413,9 @@ export const CompetitorsGridView: React.FC = ({ compet }, [highlightedRowId, anchorRowId, apiRef]); const activeDetailCompetitor = React.useMemo(() => { - if (!showLocationDetail || !highlightedRowId) return null; + if (!selectedPlaceId || !highlightedRowId) return null; return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === highlightedRowId); - }, [showLocationDetail, highlightedRowId, competitors]); + }, [selectedPlaceId, highlightedRowId, competitors]); return (
= ({ compet return String(params.id) === highlightedRowId ? 'row-custom-highlighted' : ''; }} onRowClick={(params) => { - setHighlightedRowId(String(params.id)); - setAnchorRowId(String(params.id)); + const id = String(params.id); + setHighlightedRowId(id); + setAnchorRowId(id); + onSelectPlace?.(id, 'toggle'); }} onRowSelectionModelChange={(newSelection) => { // Handle Array, Object with ids, Set, or generic Iterable @@ -529,25 +548,6 @@ export const CompetitorsGridView: React.FC = ({ compet
- {showLocationDetail && activeDetailCompetitor && ( - <> - {/* Drag Handle */} -
- -
- - {/* Property Pane */} -
- setShowLocationDetail(false)} - /> -
- - )}
); }; diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index 3c1a5af4..8bc21fcc 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -110,6 +110,8 @@ interface CompetitorsMapViewProps { onClosePosterMode?: () => void; posterTheme?: string; setPosterTheme?: (theme: string) => void; + selectedPlaceId?: string | null; + onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void; } @@ -181,7 +183,7 @@ const renderPopupHtml = (competitor: CompetitorFull) => { `; }; -export const CompetitorsMapView: React.FC = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme }) => { +export const CompetitorsMapView: React.FC = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace }) => { const features: MapFeatures = useMemo(() => { if (isPosterMode) { return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false }; @@ -214,6 +216,47 @@ export const CompetitorsMapView: React.FC = ({ competit // Selection and Sidebar State const [selectedLocation, setSelectedLocation] = useState(null); + + const handleSelectLocation = useCallback((loc: CompetitorFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => { + setSelectedLocation(loc); + onSelectPlace?.(loc?.place_id || null, behavior); + }, [onSelectPlace]); + + // Add logic to label locations A, B, C... + const validLocations = useMemo(() => { + return competitors + .map((c, i) => { + const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat; + const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon; + + if (lat !== undefined && lon !== undefined && lat !== null && lon !== null) { + return { + ...c, + lat: Number(lat), + lon: Number(lon), + label: String.fromCharCode(65 + (i % 26)) + (Math.floor(i / 26) > 0 ? Math.floor(i / 26) : '') + }; + } + return null; + }) + .filter((c): c is NonNullable => c !== null); + }, [competitors]); + + const locationIds = useMemo(() => { + return validLocations.map(l => l.place_id).sort().join(','); + }, [validLocations]); + + // Sync local selection with prop + useEffect(() => { + if (selectedPlaceId) { + const loc = validLocations.find(l => l.place_id === selectedPlaceId); + if (loc && loc.place_id !== selectedLocation?.place_id) { + setSelectedLocation(loc); + } + } else { + setSelectedLocation(null); + } + }, [selectedPlaceId, validLocations]); const [gadmPickerActive, setGadmPickerActive] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(features.showSidebar ?? false); @@ -237,7 +280,7 @@ export const CompetitorsMapView: React.FC = ({ competit // Layer Toggles const [showDensity, setShowDensity] = useState(false); - const [showCenters, setShowCenters] = useState(true); + const [showCenters, setShowCenters] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(400); const [isResizing, setIsResizing] = useState(false); @@ -248,9 +291,11 @@ export const CompetitorsMapView: React.FC = ({ competit const posterTheme = controlledPosterTheme ?? localPosterTheme; const setPosterTheme = setControlledPosterTheme ?? setLocalPosterTheme; - // Info Panel State const [infoPanelOpen, setInfoPanelOpen] = useState(false); + const handleCloseLocationDetail = useCallback(() => handleSelectLocation(null), [handleSelectLocation]); + const handleCloseInfoPanel = useCallback(() => setInfoPanelOpen(false), []); + const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(onMapCenterUpdate); // Auto-load GADM region boundaries when enableAutoRegions is on @@ -342,29 +387,6 @@ export const CompetitorsMapView: React.FC = ({ competit // Enrichment Hook - NOW PASSED VIA PROPS // const { enrich, isEnriching, progress: enrichmentProgress } = useLocationEnrichment(); - // Add logic to label locations A, B, C... - const validLocations = useMemo(() => { - return competitors - .map((c, i) => { - const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat; - const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon; - - if (lat !== undefined && lon !== undefined && lat !== null && lon !== null) { - return { - ...c, - lat: Number(lat), - lon: Number(lon), - label: String.fromCharCode(65 + (i % 26)) + (Math.floor(i / 26) > 0 ? Math.floor(i / 26) : '') - }; - } - return null; - }) - .filter((c): c is NonNullable => c !== null); - }, [competitors]); - - const locationIds = useMemo(() => { - return validLocations.map(l => l.place_id).sort().join(','); - }, [validLocations]); // Memoize the polygons FeatureCollection to avoid creating a new object reference on every render. // Without this, RegionLayers re-pushes GeoJSON data to the map source on every single render. @@ -584,7 +606,7 @@ export const CompetitorsMapView: React.FC = ({ competit e.stopPropagation(); if (e.key !== 'Escape' && nextLoc) { - setSelectedLocation(nextLoc); + handleSelectLocation(nextLoc, 'select'); if (map.current) { map.current.flyTo({ center: [nextLoc.lon, nextLoc.lat], @@ -754,10 +776,10 @@ export const CompetitorsMapView: React.FC = ({ competit competitors={validLocations} isDarkStyle={mapStyleKey === 'dark'} onSelect={(loc) => { - setSelectedLocation(loc); + handleSelectLocation(loc); setInfoPanelOpen(false); }} - selectedId={selectedLocation?.place_id} + selectedId={selectedPlaceId} /> {isPosterMode && ( = ({ competit {/* Footer: Status Info & Toggles */} = ({ competit - {/* Resizable Handle and Property Pane */} - {((features.enableLocationDetails && selectedLocation) || (features.enableInfoPanel && infoPanelOpen)) && ( + {/* Info Panel (kept as sidebar-capable for now if it's separate from LocationDetail) */} + {features.enableInfoPanel && infoPanelOpen && ( <> {/* Drag Handle */}
= ({ competit {/* Property Pane */}
- {features.enableLocationDetails && selectedLocation ? ( - setSelectedLocation(null)} - /> - ) : features.enableInfoPanel && infoPanelOpen ? ( - setInfoPanelOpen(false)} - lat={mapInternals?.lat} - lng={mapInternals?.lng} - locationName={currentCenterLabel} - /> - ) : null} +
)} diff --git a/packages/ui/src/modules/places/InfoPanel.tsx b/packages/ui/src/modules/places/InfoPanel.tsx index 99d12a47..5056309c 100644 --- a/packages/ui/src/modules/places/InfoPanel.tsx +++ b/packages/ui/src/modules/places/InfoPanel.tsx @@ -24,23 +24,28 @@ interface InfoPanelProps { locationName?: string | null; } -export const InfoPanel: React.FC = ({ isOpen, onClose, lat, lng, locationName }) => { +export const InfoPanel = React.memo(({ isOpen, onClose, lat, lng, locationName }: InfoPanelProps) => { const [articles, setArticles] = useState([]); const [llmInfo, setLlmInfo] = useState(null); const [loadingWiki, setLoadingWiki] = useState(false); const [loadingLlm, setLoadingLlm] = useState(false); const [errorWiki, setErrorWiki] = useState(null); + // Stability check for coordinates to avoid fetching on microscopic jitter + const stableLat = lat ? Math.round(lat * 1000) / 1000 : null; + const stableLng = lng ? Math.round(lng * 1000) / 1000 : null; + // Fetch Wiki Data useEffect(() => { - if (!isOpen || !lat || !lng) return; + if (!isOpen || !stableLat || !stableLng) return; const fetchArticles = async () => { + console.log("InfoPanel [Fetch Triggered]", stableLat, stableLng); setLoadingWiki(true); setErrorWiki(null); try { const apiUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || ''; - const res = await fetch(`${apiUrl}/api/locations/wiki?lat=${lat}&lon=${lng}&limit=20`); + const res = await fetch(`${apiUrl}/api/locations/wiki?lat=${stableLat}&lon=${stableLng}&limit=20`); if (res.ok) { const json = await res.json(); setArticles(json.data || []); @@ -57,14 +62,13 @@ export const InfoPanel: React.FC = ({ isOpen, onClose, lat, lng, const timer = setTimeout(fetchArticles, 500); return () => clearTimeout(timer); - }, [isOpen, lat, lng]); + }, [isOpen, stableLat, stableLng]); // Fetch LLM Info useEffect(() => { if (!isOpen || !locationName) return; const fetchLlmInfo = async () => { - return setLoadingLlm(true); setLlmInfo(null); try { @@ -82,7 +86,7 @@ export const InfoPanel: React.FC = ({ isOpen, onClose, lat, lng, }; // Debounce slightly to avoid rapid updates if panning quickly changes the name - const timer = setTimeout(fetchLlmInfo, 500); + const timer = setTimeout(fetchLlmInfo, 1000); // 1s debounce for LLM return () => clearTimeout(timer); }, [isOpen, locationName]); @@ -222,4 +226,4 @@ export const InfoPanel: React.FC = ({ isOpen, onClose, lat, lng,
); -}; +}); diff --git a/packages/ui/src/modules/places/LocationDetail.tsx b/packages/ui/src/modules/places/LocationDetail.tsx index 8cf46390..1a6e36fd 100644 --- a/packages/ui/src/modules/places/LocationDetail.tsx +++ b/packages/ui/src/modules/places/LocationDetail.tsx @@ -10,7 +10,7 @@ import MarkdownRenderer from '../../components/MarkdownRenderer'; import { T, translate } from '../../i18n'; // Extracted Presentation Component -export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }> = ({ competitor, onClose, livePhotos }) => { +export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }) => { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview'); @@ -445,7 +445,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose? /> ); -}; +}); const LocationDetail: React.FC = () => { const { place_id } = useParams<{ place_id: string }>(); diff --git a/packages/ui/src/modules/places/components/MapFooter.tsx b/packages/ui/src/modules/places/components/MapFooter.tsx index 4a3f0af6..eb9e7d0c 100644 --- a/packages/ui/src/modules/places/components/MapFooter.tsx +++ b/packages/ui/src/modules/places/components/MapFooter.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import maplibregl from 'maplibre-gl'; import { Map as MapIcon, Loader2, Locate, Maximize, Minimize, Focus, Sun, Moon } from 'lucide-react'; import type { MapStyleKey } from './map-styles'; export interface MapFooterProps { + map?: maplibregl.Map | null; currentCenterLabel?: string | null; mapInternals?: { lat: number; lng: number; zoom: number } | null; isLocating?: boolean; @@ -16,6 +18,7 @@ export interface MapFooterProps { } export function MapFooter({ + map, currentCenterLabel, mapInternals, isLocating = false, @@ -27,6 +30,34 @@ export function MapFooter({ onStyleChange, children }: MapFooterProps) { + const [liveCoords, setLiveCoords] = useState<{ lat: number; lng: number; zoom: number } | null>(null); + + // Attach localized move listener for live coordinate display + // This avoids triggering parent re-renders while still providing live info + useEffect(() => { + if (!map) return; + + const handleMove = () => { + const center = map.getCenter(); + setLiveCoords({ + lat: center.lat, + lng: center.lng, + zoom: map.getZoom() + }); + }; + + map.on('move', handleMove); + // Initial sync + handleMove(); + + return () => { + map.off('move', handleMove); + }; + }, [map]); + + // Use liveCoords for high-frequency display, fallback to mapInternals prop for static initialization + const displayCoords = liveCoords || mapInternals; + return (
@@ -39,11 +70,11 @@ export function MapFooter({ Pan map to view details )} - {mapInternals && ( + {displayCoords && (
- Lat: {mapInternals.lat.toFixed(4)} - Lng: {mapInternals.lng.toFixed(4)} - Zoom: {mapInternals.zoom.toFixed(1)} + Lat: {displayCoords.lat.toFixed(4)} + Lng: {displayCoords.lng.toFixed(4)} + Zoom: {displayCoords.zoom.toFixed(1)}
)}
diff --git a/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx b/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx index b3936dd7..1f9984a7 100644 --- a/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx @@ -58,12 +58,14 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS } return () => { - map.off('style.load', initSources); + if (map && map.getStyle()) { + map.off('style.load', initSources); + } }; }, [map]); useEffect(() => { - if (!map || !map.getSource('live-areas')) return; + if (!map || !map.getStyle() || !map.getSource('live-areas')) return; const features = liveAreas.map(a => { // Assuming the boundary comes as geojson @@ -78,14 +80,16 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS return null; }).filter(Boolean); - (map.getSource('live-areas') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: features as any - }); + try { + (map.getSource('live-areas') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: features as any + }); + } catch (e) {} }, [map, liveAreas]); useEffect(() => { - if (!map || !map.getSource('live-nodes')) return; + if (!map || !map.getStyle() || !map.getSource('live-nodes')) return; const features = liveNodes.map(n => { // Resolve coordinates from multiple possible formats @@ -116,10 +120,12 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS return null; }).filter(Boolean); - (map.getSource('live-nodes') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: features as any - }); + try { + (map.getSource('live-nodes') as maplibregl.GeoJSONSource).setData({ + type: 'FeatureCollection', + features: features as any + }); + } catch (e) {} }, [map, liveNodes]); return null; diff --git a/packages/ui/src/modules/places/components/map-layers/LocationLayers.tsx b/packages/ui/src/modules/places/components/map-layers/LocationLayers.tsx index 252b91d1..54bdef75 100644 --- a/packages/ui/src/modules/places/components/map-layers/LocationLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/LocationLayers.tsx @@ -41,7 +41,7 @@ export function LocationLayers({ const isDarkStyleRef = useRef(isDarkStyle); isDarkStyleRef.current = isDarkStyle; - + const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; @@ -157,13 +157,13 @@ export function LocationLayers({ const clusterId = features[0].properties.cluster_id; const source = map.getSource('locations-source') as maplibregl.GeoJSONSource; - + try { // MapLibre v5+ getClusterExpansionZoom returns a Promise and doesn't take a callback const zoom = await (source as any).getClusterExpansionZoom(clusterId); map.easeTo({ center: (features[0].geometry as any).coordinates, - zoom: zoom + //zoom: zoom }); } catch (err) { console.error('Error expanding cluster', err); @@ -200,21 +200,23 @@ export function LocationLayers({ map.on('style.load', setupLayers); return () => { - map.off('styledata', setupLayers); - map.off('style.load', setupLayers); - if (map.getSource('locations-source')) { - if (map.getLayer('clusters')) map.removeLayer('clusters'); - if (map.getLayer('cluster-count')) map.removeLayer('cluster-count'); - if (map.getLayer('unclustered-point-circle')) map.removeLayer('unclustered-point-circle'); - if (map.getLayer('unclustered-point-label')) map.removeLayer('unclustered-point-label'); - map.removeSource('locations-source'); + if (map && map.getStyle()) { + map.off('styledata', setupLayers); + map.off('style.load', setupLayers); + if (map.getSource('locations-source')) { + if (map.getLayer('clusters')) map.removeLayer('clusters'); + if (map.getLayer('cluster-count')) map.removeLayer('cluster-count'); + if (map.getLayer('unclustered-point-circle')) map.removeLayer('unclustered-point-circle'); + if (map.getLayer('unclustered-point-label')) map.removeLayer('unclustered-point-label'); + map.removeSource('locations-source'); + } } }; }, [map, competitors]); // Update data when it changes useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; const source = map.getSource('locations-source') as maplibregl.GeoJSONSource; if (source) { source.setData(data); @@ -223,7 +225,7 @@ export function LocationLayers({ // Update style-dependent properties useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; if (map.getLayer('unclustered-point-circle')) { map.setPaintProperty('unclustered-point-circle', 'circle-color', [ 'case', diff --git a/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx b/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx index 80415df3..5a0d666f 100644 --- a/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/RegionLayers.tsx @@ -131,9 +131,9 @@ export function RegionLayers({ map.on('style.load', setupMapLayers); return () => { - map.off('styledata', setupMapLayers); - map.off('style.load', setupMapLayers); - if (map.getStyle()) { + if (map && map.getStyle()) { + map.off('styledata', setupMapLayers); + map.off('style.load', setupMapLayers); if (map.getLayer('polygons-fill')) map.removeLayer('polygons-fill'); if (map.getLayer('polygons-line')) map.removeLayer('polygons-line'); if (map.getLayer('bboxes-fill')) map.removeLayer('bboxes-fill'); @@ -170,7 +170,7 @@ export function RegionLayers({ // Update GHS Centers Source useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; try { if (showCenters && pickerPolygons && pickerPolygons.length > 0) { const features: any[] = []; @@ -226,7 +226,7 @@ export function RegionLayers({ // Update Bboxes and Polygons Features useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; if ((window as any).__MAP_PERF_DEBUG__) { const fc = polygonsFeatureCollection as any; console.log(`[MapPerf] RegionLayers setData() called — polygons features: ${fc?.features?.length ?? 0}`); diff --git a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx index 71c338a5..b38e3b9a 100644 --- a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx @@ -204,13 +204,13 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath map.on('style.load', setupMapLayers); return () => { - map.off('styledata', setupMapLayers); - map.off('style.load', setupMapLayers); - if (scannerMarkerRef.current) { - scannerMarkerRef.current.remove(); - scannerMarkerRef.current = null; - } - if (map.getStyle()) { + if (map && map.getStyle()) { + map.off('styledata', setupMapLayers); + map.off('style.load', setupMapLayers); + if (scannerMarkerRef.current) { + scannerMarkerRef.current.remove(); + scannerMarkerRef.current = null; + } if (map.getLayer('simulator-grid-fill')) map.removeLayer('simulator-grid-fill'); if (map.getLayer('simulator-path-line')) map.removeLayer('simulator-path-line'); if (map.getSource('simulator-grid')) map.removeSource('simulator-grid'); @@ -236,7 +236,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath // Update grid + path data useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; try { if (map.getSource('simulator-grid')) (map.getSource('simulator-grid') as maplibregl.GeoJSONSource).setData(simulatorData || emptyFc as any); if (map.getSource('simulator-path')) (map.getSource('simulator-path') as maplibregl.GeoJSONSource).setData(simulatorPath || emptyFc as any); @@ -247,7 +247,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath // Update pacman DOM marker position + rotation useEffect(() => { - if (!map) return; + if (!map || !map.getStyle()) return; const fc = simulatorScanner as any; const feature = fc?.features?.[0]; diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index ff32d5a1..5545f024 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -392,10 +392,18 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className async function handleSelectRegion(region: any, forceLevel?: number, isMulti: boolean = false) { const { levelOption, resolutionOption, enrich } = stateRef.current; const gadmLevel = region.raw?.level ?? (region.level !== undefined ? region.level : levelOption); - const gid = region.gid || region[`GID_${gadmLevel}`] || region.GID_0; + + // Robust GID resolution: prioritization + // 1. region.gid (if explicitly passed) + // 2. region.GID_{gadmLevel} (specific level match) + // 3. Fallback to GID_0 ONLY if gadmLevel is 0, otherwise it's a failure (don't snap to country) + const gid = region.gid || region[`GID_${gadmLevel}`] || (gadmLevel === 0 ? region.GID_0 : undefined); const name = region.gadmName || region.name || region[`NAME_${gadmLevel}`] || region.NAME_0; - if (!gid) return; + if (!gid) { + console.warn('GadmPicker: Could not resolve gid for region', region, 'at level', gadmLevel); + return; + } if (!isMulti) { if (isFetchingSelectionRef.current) return; @@ -589,9 +597,25 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className // Import initialRegions prop (same logic as handleImportJson) useEffect(() => { - if (!initialRegions || initialRegions.length === 0) return; + if (!initialRegions || initialRegions.length === 0) { + if (active && importedInitialRef.current !== null) { + // If it became empty, we SHOULD clear + handleClearAll(); + importedInitialRef.current = null; + } + return; + } + const key = initialRegions.map(r => r.gid).sort().join(','); if (importedInitialRef.current === key) return; + + // Check if current selectedRegions already contains exactly these gids + const currentGids = selectedRegions.map(r => r.gid).sort().join(','); + if (currentGids === key && importedInitialRef.current !== null) { + importedInitialRef.current = key; + return; + } + importedInitialRef.current = key; // Full reset, same as import handler @@ -603,7 +627,9 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className setGeojsons({}); for (const item of initialRegions) { - const raw = (item as any).raw ? { ...((item as any).raw), level: item.level } : { gid: item.gid, gadmName: item.name, level: item.level }; + const raw = (item as any).raw + ? { ...((item as any).raw), gid: item.gid, level: item.level } + : { gid: item.gid, gadmName: item.name, level: item.level }; handleSelectRegion(raw, item.level, true); } }, [initialRegions]); diff --git a/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx index 98ccbeda..ee0e48b4 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx @@ -12,7 +12,7 @@ export interface GadmRegionCollectorProps { } export function GadmRegionCollector({ resolutions = {}, - onChangeResolution = () => {} + onChangeResolution = () => { } }: GadmRegionCollectorProps) { const { roots, setRoots, @@ -26,7 +26,7 @@ export function GadmRegionCollector({ const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [loadingSuggestions, setLoadingSuggestions] = useState(false); - + const suggestionsWrapRef = useRef(null); const inputRef = useRef(null); const [isLocating, setIsLocating] = useState(false); @@ -95,25 +95,27 @@ export function GadmRegionCollector({ if (data && !data.error && data.latitude && data.longitude) { const hierarchy = await fetchRegionHierarchy(data.latitude, data.longitude); if (hierarchy && hierarchy.length > 0) { - const pathGids = hierarchy.map((d: any) => d.gid); + const l0 = hierarchy[0]; + const rootNode: GadmNode = { name: l0.gadmName || l0.name || l0.gid, gid: l0.gid, level: l0.level, hasChildren: l0.level < 5, - data: l0 + data: l0 }; - + console.log('root node', rootNode) + setRoots(prev => { if (prev.find(p => p.gid === rootNode.gid)) return prev; return [...prev, rootNode]; }); setTimeout(() => { - treeApiRef.current?.expandPath(pathGids); + // treeApiRef.current?.expandPath(pathGids); }, 100); - + const lastNode = hierarchy[hierarchy.length - 1]; setRegionQuery(lastNode.gadmName || lastNode.name || lastNode.gid); } @@ -135,12 +137,12 @@ export function GadmRegionCollector({ // Ensure tree path expands to existing selections (e.g. when returning from back-navigation) useEffect(() => { if (selectedNodes.length === 0 || roots.length === 0) return; - + let expandedAnything = false; for (const node of selectedNodes) { const data = node.data; if (!data) continue; - + const pathGids: string[] = []; for (let i = 0; i <= node.level; i++) { if (data[`GID_${i}`]) { @@ -156,7 +158,7 @@ export function GadmRegionCollector({ } }, [roots.length]); // Relies on roots being loaded - + useEffect(() => { if (!regionQuery || regionQuery.length < 2) { setSuggestions([]); return; } const id = setTimeout(async () => { @@ -212,7 +214,7 @@ export function GadmRegionCollector({ {loadingSuggestions ? ( ) : ( -
- +
{roots.length === 0 && selectedNodes.length === 0 ? (
@@ -259,48 +261,57 @@ export function GadmRegionCollector({
{roots.length > 0 && (
- n.gid)} - fetchChildren={async (node) => { - const children = await fetchRegionChildren(node.gid, node.level + 1); - const seen = new Set(); - return children.map((c: any) => { - const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown'; - return { - name: actualName, - gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(), - level: node.level + 1, - hasChildren: node.level + 1 < 5, - label: c.LABEL || undefined, - data: c - }; - }).filter((c: any) => { - if (seen.has(c.gid)) return false; - seen.add(c.gid); - const display = c.label || c.name; - if (display.toLowerCase() === 'unknown') return false; - return true; - }); - }} - onActivate={(node) => { + fetchChildren={async (node) => { + const children = await fetchRegionChildren(node.gid, node.level + 1); + const seen = new Set(); + return children.map((c: any) => { + const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown'; + return { + name: actualName, + gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(), + level: node.level + 1, + hasChildren: node.level + 1 < 5, + label: c.LABEL || undefined, + data: c + }; + }).filter((c: any) => { + if (seen.has(c.gid)) return false; + seen.add(c.gid); + const display = c.label || c.name; + if (display.toLowerCase() === 'unknown') return false; + return true; + }); + }} + onActivate={(node) => { // Optional: trigger onSelect when activated, or just keep it distinct }} onSelectionChange={(treeNodes, treeSelectedIds) => { - const missingIds = Array.from(treeSelectedIds).filter(id => !treeNodes.find(n => n.gid === id)); - const missingNodes = selectedNodes.filter(n => missingIds.includes(n.gid)); - setSelectedNodes([...missingNodes, ...treeNodes]); + setSelectedNodes(prev => { + const missingIds = Array.from(treeSelectedIds).filter(id => !treeNodes.find(n => n.gid === id)); + const missingNodes = prev.filter(n => missingIds.includes(n.gid)); + + const nextNodes = [...missingNodes, ...treeNodes]; + // Simple equality check to prevent infinite re-renders + if (nextNodes.length === prev.length && nextNodes.every(n => prev.some(p => p.gid === n.gid))) { + return prev; + } + + return nextNodes; + }); }} - /> + />
)} {selectedNodes.length > 0 && (
- 0) { - const syntheticData = { ...node.data }; + const syntheticData = { ...node.data, gid: node.gid, level: node.level }; let curr: TreeRow | undefined = r; while (curr) { syntheticData[`GID_${curr.node.level}`] = curr.id; @@ -200,9 +200,9 @@ export const GadmTreePicker = React.forwardRef { if (!row.node.hasChildren || !fetchChildren) return; diff --git a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx index c142397c..84506480 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { PlusCircle, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { OngoingSearches } from './OngoingSearches'; import { GridSearchWizard } from './GridSearchWizard'; @@ -8,8 +8,22 @@ import { useRestoredSearch, RestoredSearchProvider } from './RestoredSearchConte import { useAppStore } from '@/store/appStore'; function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: string | null, setSelectedJobId: (id: string | null, forceView?: string) => void }) { + const [searchParams, setSearchParams] = useSearchParams(); const [sidebarWidth, setSidebarWidth] = useState(320); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + const isSidebarOpen = searchParams.get('hasSidebar') !== 'false'; + const setIsSidebarOpen = useCallback((open: boolean) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (open) { + next.delete('hasSidebar'); + } else { + next.set('hasSidebar', 'false'); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + const [isResizing, setIsResizing] = useState(false); const [initialWizardSettings, setInitialWizardSettings] = useState(null); const [wizardSessionKey, setWizardSessionKey] = useState(0); @@ -80,6 +94,8 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: + const handleToggleSidebar = useCallback(() => setIsSidebarOpen(!isSidebarOpen), [isSidebarOpen, setIsSidebarOpen]); + return (
@@ -137,7 +153,7 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: )} {selectedJobId ? ( - setIsSidebarOpen(!isSidebarOpen)} /> + ) : ( setSelectedJobId(id, 'map')} setIsSidebarOpen={setIsSidebarOpen} /> )} diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index b14a3e9c..a689dcd2 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -14,6 +14,8 @@ import { POSTER_THEMES } from '../utils/poster-themes'; import { type CompetitorFull } from '@polymech/shared'; import { type LogEntry } from '@/contexts/LogContext'; import ChatLogBrowser from '@/components/ChatLogBrowser'; +import { GripVertical } from 'lucide-react'; +import { LocationDetailView } from '../LocationDetail'; type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster'; @@ -45,7 +47,7 @@ const MOCK_SETTINGS = { auto_enrich: false }; -export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) { +export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => { const filteredCompetitors = React.useMemo(() => { if (!excludedTypes || excludedTypes.length === 0) return competitors; const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase())); @@ -106,6 +108,49 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta'); + // Selection and Sidebar Panel state + const [selectedPlaceId, setSelectedPlaceId] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(400); + + const isResizingRef = useRef(false); + const startResizing = useCallback(() => { + isResizingRef.current = true; + const onMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; + // Calculate width from the right edge of the screen + const newWidth = window.innerWidth - e.clientX; + if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth); + }; + const onUp = () => { + isResizingRef.current = false; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + + const activeCompetitor = React.useMemo(() => { + if (!selectedPlaceId) return null; + return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === selectedPlaceId); + }, [selectedPlaceId, competitors]); + + const handleSelectPlace = useCallback((id: string | null, behavior: 'select' | 'open' | 'toggle' = 'select') => { + const isSame = id === selectedPlaceId; + setSelectedPlaceId(id); + + if (behavior === 'open') { + setShowDetails(true); + } else if (behavior === 'toggle') { + if (isSame && showDetails) { + setShowDetails(false); + } else { + setShowDetails(true); + } + } + }, [selectedPlaceId, showDetails]); + const handleThemeChange = useCallback((e: React.ChangeEvent) => { const theme = e.target.value; setPosterTheme(theme); @@ -137,22 +182,27 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove debounce reset (prevented URL update)'); clearTimeout(mapMoveTimerRef.current); } + mapMoveTimerRef.current = setTimeout(() => { - if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → setSearchParams committed (300ms debounce)'); - setSearchParams(prev => { - const newParams = new URLSearchParams(prev); - newParams.set('mapLat', state.lat.toFixed(6)); - newParams.set('mapLng', state.lng.toFixed(6)); - newParams.set('mapZoom', state.zoom.toFixed(2)); - if (state.pitch !== undefined) newParams.set('mapPitch', state.pitch.toFixed(0)); - if (state.bearing !== undefined) newParams.set('mapBearing', state.bearing.toFixed(0)); - if (prev.get('view') !== 'poster') { - newParams.set('view', 'map'); - } - return newParams; - }, { replace: true }); + if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → window.history.replaceState committed (300ms debounce)'); + + // USE MANUAL HISTORY API: Updates the URL bar silently WITHOUT triggering React re-renders/useSearchParams + const url = new URL(window.location.href); + url.searchParams.set('mapLat', state.lat.toFixed(6)); + url.searchParams.set('mapLng', state.lng.toFixed(6)); + url.searchParams.set('mapZoom', state.zoom.toFixed(2)); + if (state.pitch !== undefined) url.searchParams.set('mapPitch', state.pitch.toFixed(0)); + if (state.bearing !== undefined) url.searchParams.set('mapBearing', state.bearing.toFixed(0)); + + // Maintain view state if it's not locked to 'poster' + if (url.searchParams.get('view') !== 'poster') { + url.searchParams.set('view', 'map'); + } + + // Silently update the browser history + window.history.replaceState(null, '', url.pathname + url.search); }, 300); - }, [setSearchParams]); + }, []); const settings = { ...MOCK_SETTINGS, excluded_types: excludedTypes }; @@ -307,88 +357,121 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
-
- {viewMode === 'grid' && ( - { })} - /> - )} - - {viewMode === 'thumb' && ( - { }} - /> - )} - - {(viewMode === 'map' || viewMode === 'poster') && ( - { - const lat = parseFloat(searchParams.get('mapLat') || ''); - const lng = parseFloat(searchParams.get('mapLng') || ''); - return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : undefined; - })()} - initialZoom={(() => { - const zoom = parseFloat(searchParams.get('mapZoom') || ''); - return !isNaN(zoom) ? zoom : undefined; - })()} - initialPitch={(() => { - const p = parseFloat(searchParams.get('mapPitch') || ''); - return !isNaN(p) ? p : undefined; - })()} - initialBearing={(() => { - const b = parseFloat(searchParams.get('mapBearing') || ''); - return !isNaN(b) ? b : undefined; - })()} - onMapMove={handleMapMove} - onClosePosterMode={() => handleViewChange('map')} - onRegionsChange={setPickedRegions} - simulatorSettings={mapSimSettings} - onSimulatorSettingsChange={setMapSimSettings} - /> - )} - - {viewMode === 'meta' && ( - { })} - /> - )} - - {viewMode === 'report' && ( - - )} - - {viewMode === 'log' && import.meta.env.DEV && sseLogs && ( -
- { }} - title="SSE Stream Events" +
+
+ {viewMode === 'grid' && ( + { })} + selectedPlaceId={selectedPlaceId} + onSelectPlace={handleSelectPlace} + isOwner={isOwner} + isPublic={isPublic} + preset={(isPublic && !isOwner) || showDetails ? 'min' : 'full'} /> + )} + + {viewMode === 'thumb' && ( + { }} + /> + )} + + {(viewMode === 'map' || viewMode === 'poster') && ( + { + const lat = parseFloat(searchParams.get('mapLat') || ''); + const lng = parseFloat(searchParams.get('mapLng') || ''); + return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : undefined; + })()} + initialZoom={(() => { + const zoom = parseFloat(searchParams.get('mapZoom') || ''); + return !isNaN(zoom) ? zoom : undefined; + })()} + initialPitch={(() => { + const p = parseFloat(searchParams.get('mapPitch') || ''); + return !isNaN(p) ? p : undefined; + })()} + initialBearing={(() => { + const b = parseFloat(searchParams.get('mapBearing') || ''); + return !isNaN(b) ? b : undefined; + })()} + onMapMove={handleMapMove} + onClosePosterMode={() => handleViewChange('map')} + onRegionsChange={setPickedRegions} + simulatorSettings={mapSimSettings} + onSimulatorSettingsChange={setMapSimSettings} + selectedPlaceId={selectedPlaceId} + onSelectPlace={handleSelectPlace} + /> + )} + + {viewMode === 'meta' && ( + { })} + /> + )} + + {viewMode === 'report' && ( + + )} + + {viewMode === 'log' && import.meta.env.DEV && sseLogs && ( +
+ { }} + title="SSE Stream Events" + /> +
+ )} +
+ + {/* Global Location Details Sidebar */} + {showDetails && activeCompetitor && ( +
+ {/* Drag Handle */} +
+ +
+ + {/* Property Pane */} +
+ setShowDetails(false)} + /> +
)}
); -} +}); diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx index 5cacc286..b4aba77e 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx @@ -1,12 +1,11 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Loader2, Search, MapPin, CheckCircle, ChevronRight, ChevronLeft, Settings } from 'lucide-react'; import { submitPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, getPlacesTypes } from '../client-gridsearch'; import { CompetitorsMapView } from '../CompetitorsMapView'; import { GadmRegionCollector } from '../gadm-picker/GadmRegionCollector'; -import { GadmNode } from '../gadm-picker/GadmTreePicker'; import { GadmPickerProvider, useGadmPicker } from '../gadm-picker/GadmPickerContext'; import { Badge } from "@/components/ui/badge"; -import { T, translate } from '../../../i18n'; +import { T, translate } from '@/i18n'; export function GridSearchWizard({ onJobSubmitted, initialSettings, setIsSidebarOpen }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any, setIsSidebarOpen?: (open: boolean) => void }) { const initNodes = initialSettings?.guidedAreas?.map((a: any) => ({ @@ -34,20 +33,19 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp const [searchQuery, setSearchQuery] = useState(initialSettings?.searchQuery || ''); // Step 3: Preview - const [showSettings, setShowSettings] = useState(false); const [simulatorSettings, setSimulatorSettings] = useState({ gridMode: 'centers', pathOrder: 'snake', groupByRegion: true, - cellSize: 5, + cellSize: 10, cellOverlap: 0, - centroidOverlap: 50, + centroidOverlap: 0, ghsFilterMode: 'OR', - maxCellsLimit: 50000, + maxCellsLimit: 10000, maxElevation: 1000, minDensity: 10, - minGhsPop: 26, - minGhsBuilt: 154, + minGhsPop: 1, + minGhsBuilt: 0, allowMissingGhs: false, bypassFilters: true, ...(initialSettings?.simulatorSettings || {}) @@ -57,6 +55,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp if (!initialSettings?.guidedAreas) return {}; const res: Record = {}; initialSettings.guidedAreas.forEach((a: any) => { res[a.gid] = a.level; }); + console.log(`resolutions:`, res); return res; }); const [excludeTypesStr, setExcludeTypesStr] = useState(initialSettings?.excludeTypes?.join(', ') || ''); @@ -68,7 +67,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp const [pastSearches, setPastSearches] = useState(() => { try { - return JSON.parse(localStorage.getItem('gridSearchPastQueries') || '[]'); + const past = JSON.parse(localStorage.getItem('gridSearchPastQueries') || '[]'); + console.log(past); + return past; } catch { return []; } @@ -163,6 +164,14 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp }; + const initialGadmRegions = useMemo(() => { + return collectedNodes.map(n => ({ + gid: n.gid, + name: n.name, + level: resolutions[n.gid] ?? n.level, + raw: n.data + })); + }, [collectedNodes, resolutions]); return (
@@ -193,7 +202,12 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
setResolutions(prev => ({ ...prev, [gid]: lvl }))} + onChangeResolution={ + (gid, lvl) => { + setResolutions(prev => ({ ...prev, [gid]: lvl })); + console.log(`resolutions collector:`, resolutions); + } + } />
)} @@ -236,7 +250,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp onMapCenterUpdate={() => { }} enrich={async () => { }} isEnriching={false} - initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level, raw: n.data }))} + initialGadmRegions={initialGadmRegions} simulatorSettings={simulatorSettings} onSimulatorSettingsChange={setSimulatorSettings} onRegionsChange={(regions) => { diff --git a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx index ccef16e4..de60ab6c 100644 --- a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx +++ b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Loader2, AlertCircle, Lock, MapPin, Share2 } from 'lucide-react'; import { toast } from 'sonner'; import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, updatePlacesGridSearchSettings } from '../client-gridsearch'; @@ -9,7 +9,7 @@ import { GridSearchStreamProvider, useGridSearchStream } from './GridSearchStrea import CollapsibleSection from '@/components/CollapsibleSection'; import { T } from '@/i18n'; -function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: any) { +const LiveGridSearchResults = React.memo(({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: any) => { const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream(); return ( @@ -34,9 +34,87 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onEx onToggleSidebar={onToggleSidebar} /> ); -} +}); -export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: string; isSidebarOpen?: boolean; onToggleSidebar?: () => void }) { +const JobMetadataDisplay = React.memo(({ jobId, jobData, foundTypes }: any) => ( +
+

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: string) => ( + + {t} + + ))} +
+ )} + + )} +
+)); + +const GadmAreasDisplay = React.memo(({ guidedAreas, isLocked, isComplete }: any) => ( +
+
+ + + GADM Areas ({guidedAreas.length}) + + {isLocked && ( + + Locked + + )} + {isComplete && ( + + Complete + + )} +
+
+ {guidedAreas.map((area: any) => ( + + + {area.name} + L{area.level} + + ))} +
+
+)); + +const SearchSettingsDisplay = React.memo(({ searchSettings }: any) => ( +
+ {searchSettings.cellSize && Cell: {searchSettings.cellSize}km} + {searchSettings.minPopDensity > 0 && Min Pop: {searchSettings.minPopDensity}} + {searchSettings.pathOrder && Path: {searchSettings.pathOrder}} + {searchSettings.gridMode && Grid: {searchSettings.gridMode}} +
+)); + +export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: string; isSidebarOpen?: boolean; onToggleSidebar?: () => void }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [competitors, setCompetitors] = useState([]); @@ -94,16 +172,16 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st return () => { active = false; }; }, [jobId, reloadKey]); - const handleUpdateExcluded = async (types: string[]) => { + const handleUpdateExcluded = useCallback(async (types: string[]) => { setExcludedTypes(types); try { await saveGridSearchExcludeTypes(types); } catch (e) { console.error('Failed to save excluded types:', e); } - }; + }, []); - const handleRetry = async () => { + const handleRetry = useCallback(async () => { try { setRetrying(true); await retryPlacesGridSearchJob(jobId); @@ -115,9 +193,9 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st } finally { setRetrying(false); } - }; + }, [jobId]); - const handleTogglePublic = async () => { + const handleTogglePublic = useCallback(async () => { const newValue = !isSharingTarget; setIsSharingTarget(newValue); try { @@ -134,7 +212,7 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st setIsSharingTarget(!newValue); // revert on failure toast.error('Failed to update sharing settings'); } - }; + }, [isSharingTarget, jobId]); if (!jobId) return null; @@ -202,7 +280,6 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st const allResults = jobData?.result?.searchResult?.results || competitors; const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[]; - console.log(JSON.stringify(foundTypes, null, 2)); return (
@@ -215,82 +292,16 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st storageKey={`gridSearchViewMetaCollapse`} className="" > -
-

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: string) => ( - - {t} - - ))} -
- )} - - )} -
+ {/* Restored GADM Areas */} {guidedAreas.length > 0 && ( -
-
- - - GADM Areas ({guidedAreas.length}) - - {isLocked && ( - - Locked - - )} - {isComplete && ( - - Complete - - )} -
-
- {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}} -
+ )}
@@ -313,4 +324,4 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
); -} +}); diff --git a/packages/ui/src/modules/places/hooks/useMapControls.ts b/packages/ui/src/modules/places/hooks/useMapControls.ts index 632738e4..fe56b913 100644 --- a/packages/ui/src/modules/places/hooks/useMapControls.ts +++ b/packages/ui/src/modules/places/hooks/useMapControls.ts @@ -11,42 +11,21 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) const userLocationMarkerRef = useRef(null); const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number, pitch?: number, bearing?: number }) => void) => { - let _moveFrames = 0; - let _moveTimer: ReturnType | null = null; - - const updateInternals = () => { - const c = map.getCenter(); - const z = map.getZoom(); - const p = map.getPitch(); - const b = map.getBearing(); - _moveFrames++; - setMapInternals({ - zoom: Math.round(z * 100) / 100, - lat: Math.round(c.lat * 10000) / 10000, - lng: Math.round(c.lng * 10000) / 10000, - pitch: Math.round(p), - bearing: Math.round(b) - }); - }; - - // Log move events/sec when debug enabled: window.__MAP_PERF_DEBUG__ = true - _moveTimer = setInterval(() => { - if ((window as any).__MAP_PERF_DEBUG__ && _moveFrames > 0) { - console.log(`[MapPerf] move events/sec: ${_moveFrames} (setMapInternals calls)`); - } - _moveFrames = 0; - }, 1000); - const handleMoveEnd = async () => { + const z = Math.round(map.getZoom() * 100) / 100; const c = map.getCenter(); - const z = map.getZoom(); - const p = map.getPitch(); - const b = map.getBearing(); + const lat = Math.round(c.lat * 10000) / 10000; + const lng = Math.round(c.lng * 10000) / 10000; + const p = Math.round(map.getPitch()); + const b = Math.round(map.getBearing()); if ((window as any).__MAP_PERF_DEBUG__) { - console.log(`[MapPerf] moveend fired — lat=${c.lat.toFixed(4)} lng=${c.lng.toFixed(4)} z=${z.toFixed(1)}`); + console.log(`[MapPerf] moveend fired — lat=${lat} lng=${lng} z=${z}`); } + // ONLY set state at the end of movement to avoid high-frequency re-renders + setMapInternals({ zoom: z, lat, lng, pitch: p, bearing: b }); + if (onMapMove) { onMapMove({ lat: c.lat, lng: c.lng, zoom: z, pitch: p, bearing: b }); } @@ -71,16 +50,13 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) } }; - map.on('move', updateInternals); map.on('moveend', handleMoveEnd); // Initial set - updateInternals(); + handleMoveEnd(); return () => { - map.off('move', updateInternals); map.off('moveend', handleMoveEnd); - if (_moveTimer) clearInterval(_moveTimer); }; }, [onMapCenterUpdate]); diff --git a/packages/ui/src/modules/places/useGridColumns.tsx b/packages/ui/src/modules/places/useGridColumns.tsx index 042a5017..0cca5467 100644 --- a/packages/ui/src/modules/places/useGridColumns.tsx +++ b/packages/ui/src/modules/places/useGridColumns.tsx @@ -7,21 +7,47 @@ import { TypeCell } from './TypeCell'; import type { CompetitorSettings } from './useCompetitorSettings'; import { T, translate } from '../../i18n'; +export type GridPreset = 'full' | 'min'; + interface UseGridColumnsProps { settings: CompetitorSettings; updateExcludedTypes: (types: string[]) => Promise; } +export const getPresetVisibilityModel = (preset: GridPreset) => { + const allFields = [ + 'thumbnail', 'title', 'email', 'phone', 'address', + 'city', 'country', 'website', 'rating', 'types', 'social' + ]; + + const minFields = ['thumbnail', 'title', 'types', 'social', 'rating']; + + const model: Record = {}; + + if (preset === 'min') { + allFields.forEach(f => { + model[f] = minFields.includes(f); + }); + } else { + // 'full' + allFields.forEach(f => { + model[f] = f !== 'city'; // default full has no city + }); + } + + return model; +}; + // Helper: find a social URL from any of the known data shapes const findSocialUrl = (row: any, platform: string): string => { const match = (p: any) => p.source === platform || p.platform === platform; const rd = row.raw_data as any; return rd?.[platform] || // raw_data.instagram etc - rd?.meta?.social?.find(match)?.url || // raw_data.meta.social[] - row.meta?.social?.find(match)?.url || // meta.social[] (nested) - row.social?.find(match)?.url || // social[] (DB singular) - row.socials?.find(match)?.url || // socials[] (SSE plural) - ''; + rd?.meta?.social?.find(match)?.url || // raw_data.meta.social[] + row.meta?.social?.find(match)?.url || // meta.social[] (nested) + row.social?.find(match)?.url || // social[] (DB singular) + row.socials?.find(match)?.url || // socials[] (SSE plural) + ''; }; export const useGridColumns = ({ @@ -29,17 +55,6 @@ export const useGridColumns = ({ updateExcludedTypes }: UseGridColumnsProps): GridColDef[] => { - // Helper for rendering social cells - const renderSocialCell = (params: GridRenderCellParams, icon: React.ReactNode, colorClass: string) => { - const url = params.value; - - return url ? ( - - {icon} - - ) : null; - }; - return React.useMemo(() => [ { field: 'thumbnail', @@ -157,9 +172,9 @@ export const useGridColumns = ({ renderCell: (params: GridRenderCellParams) => { const rating = params.value; if (rating == null) return null; - + const reviews = params.row.raw_data?.reviews || params.row.meta?.reviews || params.row.reviews; - + return (
@@ -211,7 +226,7 @@ export const useGridColumns = ({ { key: 'youtube', icon: , color: 'text-red-600 hover:text-red-800 dark:text-red-400' }, { key: 'twitter', icon: , color: 'text-blue-400 hover:text-blue-600 dark:text-blue-300' }, { key: 'github', icon: , color: 'text-gray-700 hover:text-gray-900 dark:text-gray-300' }, - { key: 'tiktok', icon: , color: 'text-gray-800 hover:text-black dark:text-gray-300' }, + { key: 'tiktok', icon: , color: 'text-gray-800 hover:text-black dark:text-gray-300' }, ]; for (const p of platforms) { @@ -232,5 +247,5 @@ export const useGridColumns = ({ ); }, }, - ], [settings, updateExcludedTypes]); + ] as GridColDef[], [settings, updateExcludedTypes]); };