map & uds bugs

This commit is contained in:
lovebird 2026-03-31 10:56:00 +02:00
parent b15592fa11
commit ac7d888f18
21 changed files with 687 additions and 455 deletions

View File

@ -294,13 +294,14 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU
return <h4 id={id} {...props}>{children}</h4>;
},
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 <p> 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 <div {...props}>{children}</div>;
}
return <p {...props}>{children}</p>;
@ -335,7 +336,20 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU
if (ids.length > 0) {
return (
<Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}>
<GalleryWidget pictureIds={ids} thumbnailLayout="grid" imageFit="cover" />
<GalleryWidget
pictureIds={ids}
thumbnailLayout="grid"
imageFit="cover"
thumbnailsPosition="bottom"
thumbnailsOrientation="horizontal"
zoomEnabled={false}
showVersions={false}
autoPlayVideos={false}
showTitle={false}
showDescription={false}
thumbnailsClassName=""
variables={{}}
/>
</Suspense>
);
}

View File

@ -51,7 +51,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
// Lazy load logic
const [isInView, setIsInView] = useState(props.loading === 'eager');
const [imgLoaded, setImgLoaded] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const ref = React.useRef<HTMLSpanElement>(null);
const imgRef = React.useRef<HTMLImageElement>(null);
@ -118,7 +118,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = 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 <div ref={ref} className={`animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
return <span ref={ref} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
}
if (error || !data) {
@ -126,11 +126,11 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
if (typeof src === 'string') {
return <img src={src} alt={alt} className={imgClassName || className} {...props} />;
}
return <div className="text-red-500 text-xs">Failed to load image</div>;
return <span className="block text-red-500 text-xs">Failed to load image</span>;
}
return (
<div className={`relative w-full h-full overflow-hidden ${className || ''}`}>
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
<picture>
{(data.sources || []).map((source, index) => (
<source key={index} srcSet={source.srcset} type={source.type} sizes={sizes} />
@ -158,11 +158,11 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
/>
</picture>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center dark:bg-gray-800 bg-gray-100/50 z-10 pointer-events-none">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
<span className="absolute inset-0 flex items-center justify-center dark:bg-gray-800 bg-gray-100/50 z-10 pointer-events-none">
<span className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></span>
</span>
)}
</div>
</span>
);
}, (prev, next) => {
// Only compare props that affect visual output — ignore callbacks

View File

@ -296,6 +296,12 @@ const TopNavigation = () => {
<T>Chat</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/products/gridsearch`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>GridSearch</T>
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>

View File

@ -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<void>;
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<CompetitorsGridViewProps> = ({ competitors, loading, settings, updateExcludedTypes }) => {
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
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<CompetitorsGridViewProps> = ({ compet
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => {
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<CompetitorsGridViewProps> = ({ compet
const [highlightedRowId, setHighlightedRowId] = useState<string | null>(null);
const [anchorRowId, setAnchorRowId] = useState<string | null>(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<CompetitorsGridViewProps> = ({ 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<CompetitorsGridViewProps> = ({ 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<CompetitorsGridViewProps> = ({ 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 (
<div
@ -441,8 +458,10 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ 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<CompetitorsGridViewProps> = ({ compet
</MuiThemeProvider>
</div>
{showLocationDetail && activeDetailCompetitor && (
<>
{/* Drag Handle */}
<div
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
onMouseDown={startResizing}
>
<GripVertical className="w-3 h-3 text-gray-400" />
</div>
{/* Property Pane */}
<div className="hidden lg:block h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0" style={{ width: sidebarWidth }}>
<LocationDetailView
competitor={activeDetailCompetitor as unknown as CompetitorFull}
onClose={() => setShowLocationDetail(false)}
/>
</div>
</>
)}
</div>
);
};

View File

@ -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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ competit
// Selection and Sidebar State
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(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<typeof c> => 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ 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<typeof c> => 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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ competit
competitors={validLocations}
isDarkStyle={mapStyleKey === 'dark'}
onSelect={(loc) => {
setSelectedLocation(loc);
handleSelectLocation(loc);
setInfoPanelOpen(false);
}}
selectedId={selectedLocation?.place_id}
selectedId={selectedPlaceId}
/>
{isPosterMode && (
<MapPosterOverlay
@ -792,6 +814,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Footer: Status Info & Toggles */}
<MapFooter
map={map.current}
currentCenterLabel={currentCenterLabel}
mapInternals={mapInternals}
isLocating={isLocating}
@ -856,8 +879,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
</MapFooter>
</div>
{/* 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 */}
<div
@ -869,20 +892,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Property Pane */}
<div className="hidden lg:block h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative" style={{ width: sidebarWidth }}>
{features.enableLocationDetails && selectedLocation ? (
<LocationDetailView
competitor={selectedLocation as unknown as CompetitorFull}
onClose={() => setSelectedLocation(null)}
/>
) : features.enableInfoPanel && infoPanelOpen ? (
<InfoPanel
isOpen={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
lat={mapInternals?.lat}
lng={mapInternals?.lng}
locationName={currentCenterLabel}
/>
) : null}
<InfoPanel
isOpen={infoPanelOpen}
onClose={handleCloseInfoPanel}
lat={mapInternals?.lat}
lng={mapInternals?.lng}
locationName={currentCenterLabel}
/>
</div>
</>
)}

View File

@ -24,23 +24,28 @@ interface InfoPanelProps {
locationName?: string | null;
}
export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng, locationName }) => {
export const InfoPanel = React.memo(({ isOpen, onClose, lat, lng, locationName }: InfoPanelProps) => {
const [articles, setArticles] = useState<WikiResult[]>([]);
const [llmInfo, setLlmInfo] = useState<LlmInfo | null>(null);
const [loadingWiki, setLoadingWiki] = useState(false);
const [loadingLlm, setLoadingLlm] = useState(false);
const [errorWiki, setErrorWiki] = useState<string | null>(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<InfoPanelProps> = ({ 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<InfoPanelProps> = ({ 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<InfoPanelProps> = ({ isOpen, onClose, lat, lng,
</div>
</div>
);
};
});

View File

@ -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?
/>
</div >
);
};
});
const LocationDetail: React.FC = () => {
const { place_id } = useParams<{ place_id: string }>();

View File

@ -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 (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20 shrink-0 w-full overflow-hidden">
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
@ -39,11 +70,11 @@ export function MapFooter({
<span>Pan map to view details</span>
)}
{mapInternals && (
{displayCoords && (
<div className="font-mono flex gap-4 opacity-80 pl-4 border-l border-gray-300 dark:border-gray-600">
<span>Lat: {mapInternals.lat.toFixed(4)}</span>
<span>Lng: {mapInternals.lng.toFixed(4)}</span>
<span>Zoom: {mapInternals.zoom.toFixed(1)}</span>
<span>Lat: {displayCoords.lat.toFixed(4)}</span>
<span>Lng: {displayCoords.lng.toFixed(4)}</span>
<span>Zoom: {displayCoords.zoom.toFixed(1)}</span>
</div>
)}
</div>

View File

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

View File

@ -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',

View File

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

View File

@ -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];

View File

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

View File

@ -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<any[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const suggestionsWrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [isLocating, setIsLocating] = useState(false);
@ -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 ? (
<Loader2 className="w-6 h-6 animate-spin text-indigo-500" />
) : (
<button
<button
type="button"
onClick={performLocate}
disabled={isLocating}
@ -248,7 +250,7 @@ export function GadmRegionCollector({
</ul>
)}
</div>
<div className="flex-1 min-h-0 border rounded-xl overflow-hidden bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 flex flex-col">
{roots.length === 0 && selectedNodes.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400">
@ -259,48 +261,57 @@ export function GadmRegionCollector({
<div className="flex flex-col h-full overflow-hidden">
{roots.length > 0 && (
<div className="flex-1 min-h-0 border-b border-gray-200 dark:border-gray-700">
<GadmTreePicker
<GadmTreePicker
apiRef={treeApiRef}
data={roots}
data={roots}
expandMapExternal={expandMap}
setExpandMapExternal={setExpandMap}
initialSelectedIds={selectedNodes.map(n => n.gid)}
fetchChildren={async (node) => {
const children = await fetchRegionChildren(node.gid, node.level + 1);
const seen = new Set();
return children.map((c: any) => {
const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown';
return {
name: actualName,
gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(),
level: node.level + 1,
hasChildren: node.level + 1 < 5,
label: c.LABEL || undefined,
data: c
};
}).filter((c: any) => {
if (seen.has(c.gid)) return false;
seen.add(c.gid);
const display = c.label || c.name;
if (display.toLowerCase() === 'unknown') return false;
return true;
});
}}
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;
});
}}
/>
/>
</div>
)}
{selectedNodes.length > 0 && (
<div className="shrink-0 max-h-[50%] overflow-y-auto p-4 bg-gray-50/30 dark:bg-gray-800/10">
<GadmPickedRegions
<GadmPickedRegions
selectedNodes={selectedNodes}
resolutions={resolutions}
onRemove={handleRemovePicked}

View File

@ -189,7 +189,7 @@ export const GadmTreePicker = React.forwardRef<HTMLDivElement, GadmTreePickerPro
// If it doesn't have parent GID_0 but is nested, walk up to construct the hierarchy
if (!node.data?.GID_0 && r.depth > 0) {
const syntheticData = { ...node.data };
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<HTMLDivElement, GadmTreePickerPro
return { ...node, data: syntheticData };
}
// Always ensure GID of its own level is populated
if (!node.data[`GID_${node.level}`]) {
const syntheticData = { ...node.data };
// Always ensure GID of its own level and explicit gid property are populated
if (!node.data[`GID_${node.level}`] || !node.data.gid) {
const syntheticData = { ...node.data, gid: node.gid, level: node.level };
syntheticData[`GID_${node.level}`] = node.gid;
syntheticData[`NAME_${node.level}`] = node.name;
return { ...node, data: syntheticData };
@ -211,7 +211,8 @@ export const GadmTreePicker = React.forwardRef<HTMLDivElement, GadmTreePickerPro
return node;
});
onSelectionChangeRef.current(selectedNodes, selectedIds);
}, [selectedIds]);
console.log('selected nodes updated', selectedNodes.length);
}, [selectedIds, rows, data.length]);
const toggleExpand = useCallback(async (row: TreeRow) => {
if (!row.node.hasChildren || !fetchChildren) return;

View File

@ -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<any>(null);
const [wizardSessionKey, setWizardSessionKey] = useState(0);
@ -80,6 +94,8 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
const handleToggleSidebar = useCallback(() => setIsSidebarOpen(!isSidebarOpen), [isSidebarOpen, setIsSidebarOpen]);
return (
<div className="flex h-[calc(100vh-64px)] w-full relative">
@ -137,7 +153,7 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
</button>
)}
{selectedJobId ? (
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} />
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={handleToggleSidebar} />
) : (
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} setIsSidebarOpen={setIsSidebarOpen} />
)}

View File

@ -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<string | null>(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<HTMLSelectElement>) => {
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
</div>
</div>
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
{viewMode === 'grid' && (
<CompetitorsGridView
competitors={filteredCompetitors}
loading={false}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
/>
)}
{viewMode === 'thumb' && (
<CompetitorsThumbView
competitors={filteredCompetitors}
filters={[]}
toggleFilter={() => { }}
/>
)}
{(viewMode === 'map' || viewMode === 'poster') && (
<CompetitorsMapView
preset="SearchView"
competitors={filteredCompetitors}
isPosterMode={viewMode === 'poster'}
posterTheme={posterTheme}
setPosterTheme={setPosterTheme}
onMapCenterUpdate={handleMapCenterUpdate}
enrich={dummyEnrich}
isEnriching={false}
enrichmentProgress={null}
liveAreas={liveAreas}
liveRadii={liveRadii}
liveNodes={liveNodes}
liveScanner={liveScanner}
initialGadmRegions={restoredGadmAreas}
initialSimulatorSettings={restoredState?.run?.request?.guided?.settings}
initialCenter={(() => {
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' && (
<CompetitorsMetaView
competitors={filteredCompetitors}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
/>
)}
{viewMode === 'report' && (
<CompetitorsReportView jobId={jobId} />
)}
{viewMode === 'log' && import.meta.env.DEV && sseLogs && (
<div className="h-full">
<ChatLogBrowser
logs={sseLogs}
clearLogs={() => { }}
title="SSE Stream Events"
<div className="flex-1 flex flex-row min-h-0 overflow-hidden">
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
{viewMode === 'grid' && (
<CompetitorsGridView
competitors={filteredCompetitors}
loading={false}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
selectedPlaceId={selectedPlaceId}
onSelectPlace={handleSelectPlace}
isOwner={isOwner}
isPublic={isPublic}
preset={(isPublic && !isOwner) || showDetails ? 'min' : 'full'}
/>
)}
{viewMode === 'thumb' && (
<CompetitorsThumbView
competitors={filteredCompetitors}
filters={[]}
toggleFilter={() => { }}
/>
)}
{(viewMode === 'map' || viewMode === 'poster') && (
<CompetitorsMapView
preset="SearchView"
competitors={filteredCompetitors}
isPosterMode={viewMode === 'poster'}
posterTheme={posterTheme}
setPosterTheme={setPosterTheme}
onMapCenterUpdate={handleMapCenterUpdate}
enrich={dummyEnrich}
isEnriching={false}
enrichmentProgress={null}
liveAreas={liveAreas}
liveRadii={liveRadii}
liveNodes={liveNodes}
liveScanner={liveScanner}
initialGadmRegions={restoredGadmAreas}
initialSimulatorSettings={restoredState?.run?.request?.guided?.settings}
initialCenter={(() => {
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' && (
<CompetitorsMetaView
competitors={filteredCompetitors}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
/>
)}
{viewMode === 'report' && (
<CompetitorsReportView jobId={jobId} />
)}
{viewMode === 'log' && import.meta.env.DEV && sseLogs && (
<div className="h-full">
<ChatLogBrowser
logs={sseLogs}
clearLogs={() => { }}
title="SSE Stream Events"
/>
</div>
)}
</div>
{/* Global Location Details Sidebar */}
{showDetails && activeCompetitor && (
<div className="flex h-full min-h-0 shrink-0">
{/* Drag Handle */}
<div
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
onMouseDown={startResizing}
>
<GripVertical className="w-3 h-3 text-gray-400" />
</div>
{/* Property Pane */}
<div
className="h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: sidebarWidth }}
>
<LocationDetailView
competitor={activeCompetitor}
onClose={() => setShowDetails(false)}
/>
</div>
</div>
)}
</div>
</div>
);
}
});

View File

@ -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<any>({
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<string, number> = {};
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<string[]>(() => {
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 (
<div className="flex flex-col items-center justify-center p-6 h-full border-box w-full">
@ -193,7 +202,12 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
</div>
<GadmRegionCollector
resolutions={resolutions}
onChangeResolution={(gid, lvl) => setResolutions(prev => ({ ...prev, [gid]: lvl }))}
onChangeResolution={
(gid, lvl) => {
setResolutions(prev => ({ ...prev, [gid]: lvl }));
console.log(`resolutions collector:`, resolutions);
}
}
/>
</div>
)}
@ -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) => {

View File

@ -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) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
<p><span className="font-medium"><T>Job ID:</T></span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
{jobData && (
<>
{jobData.request?.search?.types && (
<p><span className="font-medium"><T>Queries:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
)}
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
<p><span className="font-medium"><T>Region:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
)}
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
<p><span className="font-medium"><T>Level:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
)}
{jobData.areas && (
<p><span className="font-medium"><T>Areas Scanned:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
)}
{jobData.generatedAt && (
<p><span className="font-medium"><T>Generated At:</T></span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
)}
{foundTypes.length > 0 && (
<div className="col-span-full mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
<span className="font-medium mr-1 text-gray-800 dark:text-gray-300"><T>Found Types:</T></span>
{foundTypes.map((t: string) => (
<span key={t} className="px-2 py-0.5 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 rounded-md text-xs font-medium">
{t}
</span>
))}
</div>
)}
</>
)}
</div>
));
const GadmAreasDisplay = React.memo(({ guidedAreas, isLocked, isComplete }: any) => (
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2 mb-2">
<MapPin className="w-3.5 h-3.5 text-amber-500" />
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<T>GADM Areas</T> ({guidedAreas.length})
</span>
{isLocked && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
<Lock className="w-3 h-3" /> <T>Locked</T>
</span>
)}
{isComplete && (
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
<T>Complete</T>
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{guidedAreas.map((area: any) => (
<span
key={area.gid}
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
title={area.gid}
>
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
{area.name}
<span className="text-[10px] opacity-60">L{area.level}</span>
</span>
))}
</div>
</div>
));
const SearchSettingsDisplay = React.memo(({ searchSettings }: any) => (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Cell:</T> {searchSettings.cellSize}km</span>}
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Min Pop:</T> {searchSettings.minPopDensity}</span>}
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Path:</T> {searchSettings.pathOrder}</span>}
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Grid:</T> {searchSettings.gridMode}</span>}
</div>
));
export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: string; isSidebarOpen?: boolean; onToggleSidebar?: () => void }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [competitors, setCompetitors] = useState<any[]>([]);
@ -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 (
<div className="flex flex-col flex-1 w-full overflow-hidden min-h-0 mt-2 dark:bg-gray-800/70 p-1">
<div className="w-full flex-1 min-h-0 flex flex-col rounded-2xl shadow-xl transition-all duration-300">
@ -215,82 +292,16 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
storageKey={`gridSearchViewMetaCollapse`}
className=""
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
<p><span className="font-medium"><T>Job ID:</T></span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
{jobData && (
<>
{jobData.request?.search?.types && (
<p><span className="font-medium"><T>Queries:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
)}
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
<p><span className="font-medium"><T>Region:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
)}
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
<p><span className="font-medium"><T>Level:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
)}
{jobData.areas && (
<p><span className="font-medium"><T>Areas Scanned:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
)}
{jobData.generatedAt && (
<p><span className="font-medium"><T>Generated At:</T></span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
)}
{foundTypes.length > 0 && (
<div className="col-span-full mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
<span className="font-medium mr-1 text-gray-800 dark:text-gray-300"><T>Found Types:</T></span>
{foundTypes.map((t: string) => (
<span key={t} className="px-2 py-0.5 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 rounded-md text-xs font-medium">
{t}
</span>
))}
</div>
)}
</>
)}
</div>
<JobMetadataDisplay jobId={jobId} jobData={jobData} foundTypes={foundTypes} />
{/* Restored GADM Areas */}
{guidedAreas.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2 mb-2">
<MapPin className="w-3.5 h-3.5 text-amber-500" />
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<T>GADM Areas</T> ({guidedAreas.length})
</span>
{isLocked && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
<Lock className="w-3 h-3" /> <T>Locked</T>
</span>
)}
{isComplete && (
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
<T>Complete</T>
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{guidedAreas.map((area: any) => (
<span
key={area.gid}
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
title={area.gid}
>
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
{area.name}
<span className="text-[10px] opacity-60">L{area.level}</span>
</span>
))}
</div>
</div>
<GadmAreasDisplay guidedAreas={guidedAreas} isLocked={isLocked} isComplete={isComplete} />
)}
{/* Search Settings Summary */}
{searchSettings && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Cell:</T> {searchSettings.cellSize}km</span>}
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Min Pop:</T> {searchSettings.minPopDensity}</span>}
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Path:</T> {searchSettings.pathOrder}</span>}
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Grid:</T> {searchSettings.gridMode}</span>}
</div>
<SearchSettingsDisplay searchSettings={searchSettings} />
)}
</CollapsibleSection>
</div>
@ -313,4 +324,4 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
</div>
</div>
);
}
});

View File

@ -11,42 +11,21 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
const userLocationMarkerRef = useRef<maplibregl.Marker | null>(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<typeof setInterval> | 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]);

View File

@ -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<void>;
}
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<string, boolean> = {};
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 ? (
<a href={url} target="_blank" rel="noopener noreferrer" className={`${colorClass} flex items-center justify-center h-full`}>
{icon}
</a>
) : 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 (
<div className="flex flex-col justify-center h-full leading-tight">
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 dark:text-gray-100">
@ -211,7 +226,7 @@ export const useGridColumns = ({
{ key: 'youtube', icon: <Youtube className="h-4 w-4" />, color: 'text-red-600 hover:text-red-800 dark:text-red-400' },
{ key: 'twitter', icon: <Twitter className="h-4 w-4" />, color: 'text-blue-400 hover:text-blue-600 dark:text-blue-300' },
{ key: 'github', icon: <Github className="h-4 w-4" />, color: 'text-gray-700 hover:text-gray-900 dark:text-gray-300' },
{ key: 'tiktok', icon: <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15.2a6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.79a8.18 8.18 0 004.76 1.52V6.88a4.84 4.84 0 01-1-.19z"/></svg>, color: 'text-gray-800 hover:text-black dark:text-gray-300' },
{ key: 'tiktok', icon: <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15.2a6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.79a8.18 8.18 0 004.76 1.52V6.88a4.84 4.84 0 01-1-.19z" /></svg>, 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]);
};