map | list : speed & nav
This commit is contained in:
parent
990475bcf3
commit
708a756a07
@ -1,6 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DataGrid, type GridColDef, type GridFilterModel, type GridPaginationModel, type GridSortModel, type GridColumnVisibilityModel } from '@mui/x-data-grid';
|
||||
import {
|
||||
DataGrid,
|
||||
useGridApiRef,
|
||||
GridToolbarContainer,
|
||||
GridToolbarColumnsButton,
|
||||
GridToolbarFilterButton,
|
||||
GridToolbarExport,
|
||||
GridToolbarQuickFilter,
|
||||
type GridColDef,
|
||||
type GridFilterModel,
|
||||
type GridPaginationModel,
|
||||
type GridSortModel,
|
||||
type GridColumnVisibilityModel
|
||||
} from '@mui/x-data-grid';
|
||||
import { type CompetitorFull } from '@polymech/shared';
|
||||
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
|
||||
import { useMuiTheme } from '@/hooks/useMuiTheme';
|
||||
@ -17,6 +30,8 @@ import {
|
||||
paramsToColumnOrder
|
||||
} from './gridUtils';
|
||||
import { useGridColumns } from './useGridColumns';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { LocationDetailView } from './LocationDetail';
|
||||
|
||||
interface CompetitorsGridViewProps {
|
||||
competitors: CompetitorFull[];
|
||||
@ -25,6 +40,27 @@ interface CompetitorsGridViewProps {
|
||||
updateExcludedTypes: (types: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
|
||||
return (
|
||||
<GridToolbarContainer className="flex w-full items-center px-2 py-1.5 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<GridToolbarColumnsButton />
|
||||
<GridToolbarFilterButton />
|
||||
<GridToolbarExport />
|
||||
<GridToolbarQuickFilter className="w-48 ml-2" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center h-8 ml-auto pr-2 transition-opacity duration-200">
|
||||
{selectedCount > 0 && (
|
||||
<span className="text-xs font-semibold px-2.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 select-none shadow-sm">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</GridToolbarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ competitors, loading, settings, updateExcludedTypes }) => {
|
||||
const muiTheme = useMuiTheme();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@ -82,6 +118,32 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
|
||||
// Selection state
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
|
||||
const apiRef = useGridApiRef();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Get Columns Definition
|
||||
const columns = useGridColumns({
|
||||
@ -237,11 +299,117 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
return ordered;
|
||||
}, [searchParams, columns, columnWidths]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (!apiRef.current) return;
|
||||
|
||||
// Get row IDs in currently sorted/filtered visual order
|
||||
const allSortedIds = apiRef.current.getSortedRowIds?.() || [];
|
||||
// Retrieve MUI's internal lookup of which rows are hidden by filters
|
||||
const filteredRowsLookup = apiRef.current.state?.filter?.filteredRowsLookup || {};
|
||||
|
||||
// Keep only rows that are NOT explicitly filtered out
|
||||
const rowIds = allSortedIds.filter(id => filteredRowsLookup[id] !== false);
|
||||
|
||||
if (rowIds.length === 0) return;
|
||||
|
||||
const maxIdx = rowIds.length - 1;
|
||||
let currentIdx = highlightedRowId ? rowIds.indexOf(highlightedRowId) : -1;
|
||||
if (currentIdx < 0) currentIdx = 0;
|
||||
|
||||
let nextIdx = currentIdx;
|
||||
let handled = false;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
nextIdx = Math.min(maxIdx, currentIdx + 1);
|
||||
handled = true;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
nextIdx = Math.max(0, currentIdx - 1);
|
||||
handled = true;
|
||||
} else if (e.key === 'Home') {
|
||||
nextIdx = 0;
|
||||
handled = true;
|
||||
} else if (e.key === 'End') {
|
||||
nextIdx = maxIdx;
|
||||
handled = true;
|
||||
} else if (e.key === 'Enter') {
|
||||
handled = true;
|
||||
setShowLocationDetail(prev => !prev);
|
||||
|
||||
} else if (e.key === ' ') {
|
||||
handled = true;
|
||||
const currentId = String(rowIds[currentIdx]);
|
||||
setSelectedRows(prev => {
|
||||
const isSelected = prev.includes(currentId);
|
||||
return isSelected ? prev.filter(x => x !== currentId) : [...prev, currentId];
|
||||
});
|
||||
setAnchorRowId(currentId);
|
||||
} else if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||
handled = true;
|
||||
setSelectedRows(rowIds.map(id => String(id)));
|
||||
} else if (e.key === 'Escape') {
|
||||
handled = true;
|
||||
setSelectedRows([]);
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // Crucial: Stop MUI from interpreting it
|
||||
e.stopPropagation();
|
||||
|
||||
if (nextIdx !== currentIdx || !highlightedRowId) {
|
||||
const nextId = String(rowIds[nextIdx]);
|
||||
setHighlightedRowId(nextId);
|
||||
|
||||
if (e.shiftKey) {
|
||||
const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx;
|
||||
const effectiveAnchor = anchorIdx >= 0 ? anchorIdx : currentIdx;
|
||||
const start = Math.min(effectiveAnchor, nextIdx);
|
||||
const end = Math.max(effectiveAnchor, nextIdx);
|
||||
const newRange = rowIds.slice(start, end + 1).map(id => String(id));
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedRows(prev => Array.from(new Set([...prev, ...newRange])));
|
||||
} else {
|
||||
setSelectedRows(newRange);
|
||||
}
|
||||
} else if (e.key !== ' ' && e.key !== 'Enter' && e.key !== 'a' && e.key !== 'Escape') {
|
||||
setAnchorRowId(nextId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
apiRef.current?.scrollToIndexes({ rowIndex: nextIdx });
|
||||
} catch (err) { }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase to intercept before DataGrid native handlers
|
||||
el.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => el.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [highlightedRowId, anchorRowId, apiRef]);
|
||||
|
||||
const activeDetailCompetitor = React.useMemo(() => {
|
||||
if (!showLocationDetail || !highlightedRowId) return null;
|
||||
return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === highlightedRowId);
|
||||
}, [showLocationDetail, highlightedRowId, competitors]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex-1 min-h-0 w-full overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex w-full h-full flex-row outline-none focus:ring-2 focus:ring-primary/20 focus:ring-inset rounded-lg"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex-1 min-h-0 min-w-0 w-full overflow-hidden flex flex-col">
|
||||
<MuiThemeProvider theme={muiTheme}>
|
||||
<DataGrid
|
||||
apiRef={apiRef}
|
||||
rows={filteredCompetitors}
|
||||
columns={orderedColumns}
|
||||
getRowId={(row) => row.place_id || row.placeId || row.id}
|
||||
@ -260,7 +428,22 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
checkboxSelection
|
||||
showToolbar
|
||||
disableRowSelectionOnClick
|
||||
slots={{
|
||||
toolbar: () => <CustomToolbar selectedCount={selectedRows.length} />
|
||||
}}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
} as any
|
||||
}}
|
||||
rowSelectionModel={{ type: 'include', ids: new Set(selectedRows) } as any}
|
||||
getRowClassName={(params) => {
|
||||
return String(params.id) === highlightedRowId ? 'row-custom-highlighted' : '';
|
||||
}}
|
||||
onRowClick={(params) => {
|
||||
setHighlightedRowId(String(params.id));
|
||||
setAnchorRowId(String(params.id));
|
||||
}}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
// Handle Array, Object with ids, Set, or generic Iterable
|
||||
let ids: any[] = [];
|
||||
@ -304,6 +487,13 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
backgroundColor: 'hsl(var(--primary) / 0.1)',
|
||||
},
|
||||
},
|
||||
'& .MuiDataGrid-row.row-custom-highlighted': {
|
||||
backgroundColor: 'hsl(var(--primary) / 0.15) !important',
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderTop: '1px solid hsl(var(--primary)) !important',
|
||||
borderBottom: '1px solid hsl(var(--primary)) !important',
|
||||
}
|
||||
},
|
||||
'& .MuiDataGrid-footerContainer': {
|
||||
borderColor: 'hsl(var(--border))',
|
||||
},
|
||||
@ -338,6 +528,26 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -226,7 +226,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
// Layer Toggles
|
||||
const [showDensity, setShowDensity] = useState(false);
|
||||
const [showCenters, setShowCenters] = useState(false);
|
||||
const [showCenters, setShowCenters] = useState(true);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(400);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@ -334,6 +334,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
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.
|
||||
const polygonsFeatureCollection = useMemo(() => {
|
||||
if (pickerPolygons.length === 0) return undefined;
|
||||
return { type: 'FeatureCollection' as const, features: pickerPolygons.flatMap(fc => fc?.features || []) };
|
||||
}, [pickerPolygons]);
|
||||
|
||||
const markersRef = useRef<maplibregl.Marker[]>([]);
|
||||
|
||||
const onMapMoveRef = useRef(onMapMove);
|
||||
@ -359,20 +366,22 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
map.current.addControl(new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }), 'top-right');
|
||||
// If container is null, it falls back to map canvas perfectly.
|
||||
map.current.addControl(new maplibregl.FullscreenControl({ container: mapWrapper.current || undefined }), 'top-right');
|
||||
// Removed broken GeolocateControl as it requires HTTPS/permission which might fail
|
||||
// We implemented a custom IP-based locate button instead.
|
||||
map.current.addControl(new maplibregl.TerrainControl({
|
||||
source: 'terrainSource',
|
||||
exaggeration: 1
|
||||
}), 'top-right');
|
||||
|
||||
// Only add TerrainControl when terrain source is available (3D style).
|
||||
// The old code injected the terrain source into every style on style.load,
|
||||
// causing expensive uncacheable DEM tile fetches even on 2D styles.
|
||||
let terrainCtrlAdded = false;
|
||||
map.current.on('style.load', () => {
|
||||
const m = map.current;
|
||||
if (!m) return;
|
||||
// Re-inject terrain for styles that don't include it (e.g. dark URL)
|
||||
if (!m.getSource('terrainSource')) {
|
||||
const terrainDef = { type: 'raster-dem' as const, url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', tileSize: 256 };
|
||||
m.addSource('terrainSource', terrainDef);
|
||||
if (m.getSource('terrainSource') && !terrainCtrlAdded) {
|
||||
terrainCtrlAdded = true;
|
||||
try {
|
||||
m.addControl(new maplibregl.TerrainControl({
|
||||
source: 'terrainSource',
|
||||
exaggeration: 1
|
||||
}), 'top-right');
|
||||
} catch (e) { /* already added */ }
|
||||
}
|
||||
});
|
||||
|
||||
@ -401,10 +410,26 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
// Create custom marker element
|
||||
const el = document.createElement('div');
|
||||
el.className = 'custom-marker';
|
||||
// Set explicit low z-index so Pacman (9999) easily over-renders it
|
||||
el.style.zIndex = '10';
|
||||
|
||||
const isDark = mapStyleKey === 'dark';
|
||||
const isSelected = selectedLocation && loc.place_id === selectedLocation.place_id;
|
||||
|
||||
// Inner div for the pin to handle hover transform isolation
|
||||
const pin = document.createElement('div');
|
||||
pin.className = 'w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold shadow-lg border-2 border-white cursor-pointer hover:bg-indigo-700 hover:scale-110 transition-transform';
|
||||
|
||||
// Theme-aware, more elegant pin styling with highlighting for the active one
|
||||
pin.className = `w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shadow-md border-[1.5px] cursor-pointer transition-all duration-300 backdrop-blur-sm ` +
|
||||
(isSelected
|
||||
? (isDark
|
||||
? 'bg-amber-500 border-amber-300 text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.5)]'
|
||||
: 'bg-amber-400 border-white text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-400/40 shadow-[0_0_15px_rgba(251,191,36,0.6)]')
|
||||
: (isDark
|
||||
? 'bg-indigo-900/80 border-indigo-400/60 text-indigo-100 hover:bg-indigo-800/90 hover:border-indigo-300 hover:scale-125'
|
||||
: 'bg-indigo-600 border-white text-white shadow-lg hover:bg-indigo-700 hover:scale-125')
|
||||
);
|
||||
|
||||
pin.innerHTML = loc.label;
|
||||
|
||||
el.appendChild(pin);
|
||||
@ -430,6 +455,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
.setPopup(popup)
|
||||
.addTo(map.current!);
|
||||
|
||||
// Ensure the maplibre container honors the z-index
|
||||
const wrapper = marker.getElement();
|
||||
if (wrapper) wrapper.style.zIndex = '10';
|
||||
|
||||
markersRef.current.push(marker);
|
||||
});
|
||||
|
||||
@ -439,7 +468,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
|
||||
map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 });
|
||||
}
|
||||
}, [locationIds, sidebarWidth, initialCenter]);
|
||||
}, [locationIds, validLocations, sidebarWidth, initialCenter, mapStyleKey, selectedLocation]);
|
||||
|
||||
|
||||
// Sync Theme/Style
|
||||
@ -457,53 +486,149 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
|
||||
|
||||
// Handle Layout Resize
|
||||
// Handle Layout Resize (debounced to avoid firing easeTo per pixel of drag)
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
setTimeout(() => map.current?.resize(), 50); // Accommodate flexbox reflow
|
||||
if (selectedLocation) {
|
||||
// Adjust padding to account for sidebar
|
||||
map.current.easeTo({
|
||||
padding: { right: 0, top: 0, bottom: 0, left: 0 },
|
||||
duration: 300
|
||||
});
|
||||
if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
if (map.current) {
|
||||
map.current.resize();
|
||||
if (selectedLocation) {
|
||||
map.current.easeTo({
|
||||
padding: { right: 0, top: 0, bottom: 0, left: 0 },
|
||||
duration: 300
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => { if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current); };
|
||||
}, [selectedLocation, sidebarWidth, leftSidebarWidth, gadmPickerActive, simulatorActive]);
|
||||
|
||||
// Resizing Handlers
|
||||
const startResizing = useCallback(() => setIsResizing(true), []);
|
||||
const stopResizing = useCallback(() => setIsResizing(false), []);
|
||||
const resize = useCallback((e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
// Resizing Handlers — use refs so global listeners are only attached during active drag
|
||||
const isResizingRef = useRef(false);
|
||||
const isLeftResizingRef = useRef(false);
|
||||
|
||||
const startResizing = useCallback(() => {
|
||||
isResizingRef.current = true;
|
||||
setIsResizing(true);
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isResizingRef.current) return;
|
||||
const newWidth = window.innerWidth - e.clientX;
|
||||
if (newWidth > 300 && newWidth < 800) {
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
const startLeftResizing = useCallback(() => setIsLeftResizing(true), []);
|
||||
const stopLeftResizing = useCallback(() => setIsLeftResizing(false), []);
|
||||
const resizeLeft = useCallback((e: MouseEvent) => {
|
||||
if (isLeftResizing) {
|
||||
setLeftSidebarWidth(Math.max(100, e.clientX));
|
||||
}
|
||||
}, [isLeftResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', stopResizing);
|
||||
window.addEventListener('mousemove', resizeLeft);
|
||||
window.addEventListener('mouseup', stopLeftResizing);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', stopResizing);
|
||||
window.removeEventListener('mousemove', resizeLeft);
|
||||
window.removeEventListener('mouseup', stopLeftResizing);
|
||||
if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth);
|
||||
};
|
||||
}, [resize, stopResizing, resizeLeft, stopLeftResizing]);
|
||||
const onUp = () => {
|
||||
isResizingRef.current = false;
|
||||
setIsResizing(false);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
const startLeftResizing = useCallback(() => {
|
||||
isLeftResizingRef.current = true;
|
||||
setIsLeftResizing(true);
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isLeftResizingRef.current) return;
|
||||
setLeftSidebarWidth(Math.max(100, e.clientX));
|
||||
};
|
||||
const onUp = () => {
|
||||
isLeftResizingRef.current = false;
|
||||
setIsLeftResizing(false);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
// Keyboard Navigation for Detail View
|
||||
useEffect(() => {
|
||||
if (!features.enableLocationDetails || !selectedLocation) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if in inputs
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
|
||||
// Only intercept keys we care about to prevent interfering with other global shortcuts
|
||||
if (!['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Escape'].includes(e.key)) return;
|
||||
|
||||
let handled = false;
|
||||
let nextLoc: typeof validLocations[0] | null = null;
|
||||
const current = selectedLocation;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedLocation(null);
|
||||
handled = true;
|
||||
} else {
|
||||
handled = true;
|
||||
|
||||
// Spatial Navigation logic
|
||||
const currentLoc = validLocations.find(l => l.place_id === current.place_id);
|
||||
if (currentLoc) {
|
||||
const candidates = validLocations.filter(loc => loc.place_id !== current.place_id);
|
||||
let bestDistance = Infinity;
|
||||
|
||||
candidates.forEach(loc => {
|
||||
const dx = loc.lon - currentLoc.lon;
|
||||
const dy = loc.lat - currentLoc.lat;
|
||||
|
||||
// Check if it's in the correct direction hemisphere
|
||||
let isValidDirection = false;
|
||||
if (e.key === 'ArrowRight' && dx > 0) isValidDirection = true;
|
||||
if (e.key === 'ArrowLeft' && dx < 0) isValidDirection = true;
|
||||
if (e.key === 'ArrowUp' && dy > 0) isValidDirection = true;
|
||||
if (e.key === 'ArrowDown' && dy < 0) isValidDirection = true;
|
||||
|
||||
if (isValidDirection) {
|
||||
// We add a slight penalty to perpendicular movement to encourage strict directional lines,
|
||||
// but simple Euclidean distance generally works best for human expectation on maps.
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
nextLoc = loc;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Rollover if we've hit the extent
|
||||
if (!nextLoc && candidates.length > 0) {
|
||||
let wrapTarget = candidates[0];
|
||||
candidates.forEach(loc => {
|
||||
if (e.key === 'ArrowRight' && loc.lon < wrapTarget.lon) wrapTarget = loc;
|
||||
if (e.key === 'ArrowLeft' && loc.lon > wrapTarget.lon) wrapTarget = loc;
|
||||
if (e.key === 'ArrowUp' && loc.lat < wrapTarget.lat) wrapTarget = loc;
|
||||
if (e.key === 'ArrowDown' && loc.lat > wrapTarget.lat) wrapTarget = loc;
|
||||
});
|
||||
nextLoc = wrapTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key !== 'Escape' && nextLoc) {
|
||||
setSelectedLocation(nextLoc);
|
||||
if (map.current) {
|
||||
map.current.flyTo({
|
||||
center: [nextLoc.lon, nextLoc.lat],
|
||||
zoom: 15,
|
||||
padding: { right: sidebarWidth },
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase to intercept before maplibregl gets it
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [selectedLocation, validLocations, sidebarWidth, features.enableLocationDetails]);
|
||||
|
||||
// Group locations by city
|
||||
const locationsByCity = useMemo(() => {
|
||||
@ -642,7 +767,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
map={map.current}
|
||||
isDarkStyle={mapStyleKey === 'dark'}
|
||||
pickerPolygons={pickerPolygons}
|
||||
polygonsFeatureCollection={pickerPolygons.length > 0 ? { type: 'FeatureCollection', features: pickerPolygons.flatMap(fc => fc?.features || []) } : undefined}
|
||||
polygonsFeatureCollection={polygonsFeatureCollection}
|
||||
showDensity={showDensity}
|
||||
showCenters={showCenters}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered } from 'lucide-react';
|
||||
import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered, Loader2 } from 'lucide-react';
|
||||
import Lightbox from "yet-another-react-lightbox";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import { API_URL, THUMBNAIL_WIDTH } from '../../constants';
|
||||
@ -14,12 +14,27 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview');
|
||||
const [fetchedPhotos, setFetchedPhotos] = useState<any>(null);
|
||||
const [isFetchingPhotos, setIsFetchingPhotos] = useState(false);
|
||||
const showDebug = import.meta.env.VITE_LOCATION_DETAIL_DEBUG === 'true';
|
||||
|
||||
console.log(competitor);
|
||||
useEffect(() => {
|
||||
// Reset local fetched state when competitor changes
|
||||
setFetchedPhotos(null);
|
||||
setIsFetchingPhotos(false);
|
||||
|
||||
// Fetch photos on-the-fly (async, non-blocking) if we don't already have them
|
||||
if (!livePhotos && !competitor.raw_data?.google_media?.photos?.length) {
|
||||
setIsFetchingPhotos(true);
|
||||
fetchPlacePhotos(competitor.place_id)
|
||||
.then(photos => { if (photos?.photos?.length) setFetchedPhotos(photos); })
|
||||
.catch(err => console.warn('Photos fetch skipped:', err.message))
|
||||
.finally(() => setIsFetchingPhotos(false));
|
||||
}
|
||||
}, [competitor.place_id, livePhotos, competitor.raw_data?.google_media?.photos?.length]);
|
||||
|
||||
// Prefer live-fetched photos, fall back to DB-cached
|
||||
const photoSource = livePhotos || competitor.raw_data?.google_media;
|
||||
// Prefer prop-injected, then dynamically fetched, then DB-cached
|
||||
const photoSource = livePhotos || fetchedPhotos || competitor.raw_data?.google_media;
|
||||
const photos = photoSource?.photos?.map((photo: any) => ({
|
||||
src: photo.image,
|
||||
alt: competitor.title,
|
||||
@ -162,7 +177,8 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
|
||||
<div className={!onClose ? "px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700" : "mb-3"}>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 flex items-center">
|
||||
<ImageIcon className="h-5 w-5 mr-2" />
|
||||
<T>Photos</T> ({photos.length})
|
||||
<T>Photos</T> <span className="ml-1">{photos.length > 0 ? `(${photos.length})` : ''}</span>
|
||||
{isFetchingPhotos && <Loader2 className="ml-2 h-4 w-4 animate-spin text-indigo-500" />}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`grid gap-2 ${onClose ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 px-4 pb-4'}`}>
|
||||
@ -381,9 +397,9 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
|
||||
<T>Open in new tab</T>
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={competitor.website}
|
||||
className="w-full flex-1 border-0 min-h-[600px] bg-white"
|
||||
<iframe
|
||||
src={competitor.website}
|
||||
className="w-full flex-1 border-0 min-h-[600px] bg-white"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
title={translate("Homepage Preview")}
|
||||
|
||||
@ -11,6 +11,9 @@ export interface GridSearchSummary {
|
||||
level: string;
|
||||
query: string;
|
||||
countries?: string[];
|
||||
hierarchy?: string[];
|
||||
results?: number;
|
||||
emails?: number;
|
||||
generatedAt: string;
|
||||
children?: GridSearchSummary[];
|
||||
}
|
||||
|
||||
@ -82,8 +82,8 @@ export function MapFooter({
|
||||
)}
|
||||
|
||||
{onStyleChange && [
|
||||
{ key: 'light', icon: Sun, title: 'Light Mode' },
|
||||
{ key: 'osm_raster', icon: MapIcon, title: 'OSM Raster' },
|
||||
{ key: 'light', icon: Sun, title: 'Light 2D' },
|
||||
{ key: '3d', icon: MapIcon, title: '3D Terrain' },
|
||||
{ key: 'dark', icon: Moon, title: 'Dark Mode' }
|
||||
].map(({ key, icon: Icon, title }) => (
|
||||
<button
|
||||
|
||||
@ -60,11 +60,12 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
|
||||
updateCenter();
|
||||
fetchGeo();
|
||||
|
||||
map.on('move', updateCenter);
|
||||
// Use moveend instead of move — the poster footer coordinates don't need 60fps updates
|
||||
map.on('moveend', updateCenter);
|
||||
map.on('moveend', fetchGeo);
|
||||
|
||||
return () => {
|
||||
map.off('move', updateCenter);
|
||||
map.off('moveend', updateCenter);
|
||||
map.off('moveend', fetchGeo);
|
||||
}
|
||||
}, [map]);
|
||||
@ -143,8 +144,11 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (map.getLayer('poster-gadm-borders')) map.removeLayer('poster-gadm-borders');
|
||||
if (map.getSource('poster-gadm')) map.removeSource('poster-gadm');
|
||||
const style = map.getStyle();
|
||||
if (style) {
|
||||
if (map.getLayer('poster-gadm-borders')) map.removeLayer('poster-gadm-borders');
|
||||
if (map.getSource('poster-gadm')) map.removeSource('poster-gadm');
|
||||
}
|
||||
};
|
||||
}, [map, pickerPolygons, showGadmBorders, theme.text]);
|
||||
|
||||
|
||||
@ -227,6 +227,10 @@ export function RegionLayers({
|
||||
// Update Bboxes and Polygons Features
|
||||
useEffect(() => {
|
||||
if (!map) 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}`);
|
||||
}
|
||||
try {
|
||||
if (map.getSource('grid-bboxes')) (map.getSource('grid-bboxes') as maplibregl.GeoJSONSource).setData(bboxesFeatureCollection || emptyFc as any);
|
||||
if (map.getSource('grid-polygons')) (map.getSource('grid-polygons') as maplibregl.GeoJSONSource).setData(polygonsFeatureCollection || emptyFc as any);
|
||||
|
||||
@ -1,153 +1,301 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
export interface SimulatorLayersProps {
|
||||
map: maplibregl.Map | null;
|
||||
isDarkStyle: boolean;
|
||||
simulatorData?: any;
|
||||
simulatorPath?: any;
|
||||
simulatorScanner?: any;
|
||||
}
|
||||
|
||||
const emptyFc = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
const pacmanOpenSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="30" height="30"><path d="M50,50 L100,25 A50,50 0 1,0 100,75 Z" fill="#eab308"/></svg>`;
|
||||
const pacmanClosedSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="30" height="30"><circle cx="50" cy="50" r="50" fill="#eab308"/></svg>`;
|
||||
|
||||
export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath, simulatorScanner }: SimulatorLayersProps) {
|
||||
|
||||
const isDarkStyleRef = useRef(isDarkStyle);
|
||||
isDarkStyleRef.current = isDarkStyle;
|
||||
|
||||
const simulatorDataRef = useRef(simulatorData);
|
||||
simulatorDataRef.current = simulatorData;
|
||||
|
||||
const simulatorPathRef = useRef(simulatorPath);
|
||||
simulatorPathRef.current = simulatorPath;
|
||||
|
||||
const simulatorScannerRef = useRef(simulatorScanner);
|
||||
simulatorScannerRef.current = simulatorScanner;
|
||||
|
||||
// Add Sources and Layers (Mount/Unmount only!)
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const setupMapLayers = () => {
|
||||
if (!map.getStyle()) return;
|
||||
|
||||
const currentIsDark = isDarkStyleRef.current;
|
||||
|
||||
// Load Pacman Icons
|
||||
const imgOpen = new Image(30, 30);
|
||||
imgOpen.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanOpenSvg);
|
||||
imgOpen.onload = () => { if (!map.hasImage('pacman-open')) map.addImage('pacman-open', imgOpen); };
|
||||
|
||||
const imgClosed = new Image(30, 30);
|
||||
imgClosed.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(pacmanClosedSvg);
|
||||
imgClosed.onload = () => { if (!map.hasImage('pacman-closed')) map.addImage('pacman-closed', imgClosed); };
|
||||
|
||||
if (!map.getSource('simulator-grid')) {
|
||||
map.addSource('simulator-grid', { type: 'geojson', data: simulatorDataRef.current || emptyFc as any });
|
||||
}
|
||||
if (!map.getLayer('simulator-grid-fill')) {
|
||||
map.addLayer({
|
||||
id: 'simulator-grid-fill',
|
||||
type: 'fill',
|
||||
source: 'simulator-grid',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'match', ['get', 'sim_status'],
|
||||
'pending', currentIsDark ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
|
||||
'skipped', currentIsDark ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
|
||||
'processed', currentIsDark ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
|
||||
'rgba(0,0,0,0)'
|
||||
],
|
||||
'fill-outline-color': currentIsDark ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getSource('simulator-path')) {
|
||||
map.addSource('simulator-path', { type: 'geojson', data: simulatorPathRef.current || emptyFc as any });
|
||||
}
|
||||
if (!map.getLayer('simulator-path-line')) {
|
||||
map.addLayer({
|
||||
id: 'simulator-path-line',
|
||||
type: 'line',
|
||||
source: 'simulator-path',
|
||||
paint: {
|
||||
'line-color': 'rgba(59, 130, 246, 0.4)',
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [3, 3]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getSource('simulator-scanner')) {
|
||||
map.addSource('simulator-scanner', { type: 'geojson', data: simulatorScannerRef.current || emptyFc as any });
|
||||
}
|
||||
if (!map.getLayer('simulator-scanner-pacman')) {
|
||||
map.addLayer({
|
||||
id: 'simulator-scanner-pacman',
|
||||
type: 'symbol',
|
||||
source: 'simulator-scanner',
|
||||
layout: {
|
||||
'icon-image': ['coalesce', ['get', 'icon_state'], 'pacman-open'],
|
||||
'icon-size': 1.0,
|
||||
'icon-rotate': ['-', ['get', 'bearing'], 90],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (map.getStyle()) setupMapLayers();
|
||||
map.on('styledata', setupMapLayers);
|
||||
map.on('style.load', setupMapLayers);
|
||||
|
||||
return () => {
|
||||
map.off('styledata', setupMapLayers);
|
||||
map.off('style.load', setupMapLayers);
|
||||
if (map.getStyle()) {
|
||||
if (map.getLayer('simulator-grid-fill')) map.removeLayer('simulator-grid-fill');
|
||||
if (map.getLayer('simulator-path-line')) map.removeLayer('simulator-path-line');
|
||||
if (map.getLayer('simulator-scanner-pacman')) map.removeLayer('simulator-scanner-pacman');
|
||||
if (map.getSource('simulator-grid')) map.removeSource('simulator-grid');
|
||||
if (map.getSource('simulator-path')) map.removeSource('simulator-path');
|
||||
if (map.getSource('simulator-scanner')) map.removeSource('simulator-scanner');
|
||||
}
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Update Styles when theme changes
|
||||
useEffect(() => {
|
||||
if (!map || !map.getStyle()) return;
|
||||
if (map.getLayer('simulator-grid-fill')) {
|
||||
map.setPaintProperty('simulator-grid-fill', 'fill-color', [
|
||||
'match', ['get', 'sim_status'],
|
||||
'pending', isDarkStyle ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
|
||||
'skipped', isDarkStyle ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
|
||||
'processed', isDarkStyle ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
|
||||
'rgba(0,0,0,0)'
|
||||
]);
|
||||
map.setPaintProperty('simulator-grid-fill', 'fill-outline-color', isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)');
|
||||
}
|
||||
}, [map, isDarkStyle]);
|
||||
|
||||
// Update Data
|
||||
useEffect(() => {
|
||||
if (!map) 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);
|
||||
if (map.getSource('simulator-scanner')) (map.getSource('simulator-scanner') as maplibregl.GeoJSONSource).setData(simulatorScanner || emptyFc as any);
|
||||
if (map.getLayer('simulator-scanner-pacman')) map.moveLayer('simulator-scanner-pacman');
|
||||
} catch (e) {
|
||||
console.warn("Could not update simulator data", e);
|
||||
}
|
||||
}, [map, simulatorData, simulatorPath, simulatorScanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
export interface SimulatorLayersProps {
|
||||
map: maplibregl.Map | null;
|
||||
isDarkStyle: boolean;
|
||||
simulatorData?: any;
|
||||
simulatorPath?: any;
|
||||
simulatorScanner?: any;
|
||||
}
|
||||
|
||||
const emptyFc = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
// Inject scanner CSS once
|
||||
let _scannerCssInjected = false;
|
||||
function injectScannerCSS() {
|
||||
if (_scannerCssInjected) return;
|
||||
_scannerCssInjected = true;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.sim-pacman-wrap {
|
||||
width: 52px; height: 52px;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.5));
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
/* Glow ring pulse behind pacman */
|
||||
.sim-pacman-glow {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(circle, rgba(250, 204, 21, 0.25) 0%, transparent 70%);
|
||||
animation: pac-glow 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes pac-glow {
|
||||
0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.6; }
|
||||
100% { transform: translate(-50%, -50%) scale(1.3); opacity: 1; }
|
||||
}
|
||||
/* Dot trail particles */
|
||||
.sim-pacman-dots {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 0; height: 0;
|
||||
}
|
||||
.sim-pacman-dot {
|
||||
position: absolute;
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(250, 204, 21, 0.7);
|
||||
animation: pac-dot-fade 0.8s ease-out forwards;
|
||||
}
|
||||
.sim-pacman-dot:nth-child(1) { transform: translate(-18px, 0); animation-delay: 0s; }
|
||||
.sim-pacman-dot:nth-child(2) { transform: translate(-30px, 0); animation-delay: 0.15s; }
|
||||
.sim-pacman-dot:nth-child(3) { transform: translate(-42px, 0); animation-delay: 0.3s; }
|
||||
@keyframes pac-dot-fade {
|
||||
0% { opacity: 0.8; transform: translate(var(--dx, -18px), 0) scale(1); }
|
||||
100% { opacity: 0; transform: translate(var(--dx, -18px), 0) scale(0.3); }
|
||||
}
|
||||
/* The pacman SVG container */
|
||||
.sim-pacman-body {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 36px; height: 36px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
.sim-pacman-body svg {
|
||||
width: 100%; height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
/* Mouth chomp animation */
|
||||
.pac-mouth-top {
|
||||
animation: pac-chomp-top 0.35s ease-in-out infinite alternate;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.pac-mouth-bot {
|
||||
animation: pac-chomp-bot 0.35s ease-in-out infinite alternate;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
@keyframes pac-chomp-top {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(-25deg); }
|
||||
}
|
||||
@keyframes pac-chomp-bot {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(25deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function createPacmanElement(): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sim-pacman-wrap';
|
||||
el.innerHTML = `
|
||||
<div class="sim-pacman-glow"></div>
|
||||
<div class="sim-pacman-dots">
|
||||
<div class="sim-pacman-dot"></div>
|
||||
<div class="sim-pacman-dot"></div>
|
||||
<div class="sim-pacman-dot"></div>
|
||||
</div>
|
||||
<div class="sim-pacman-body">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="pac-grad" cx="40%" cy="40%" r="60%">
|
||||
<stop offset="0%" stop-color="#fde047"/>
|
||||
<stop offset="60%" stop-color="#eab308"/>
|
||||
<stop offset="100%" stop-color="#ca8a04"/>
|
||||
</radialGradient>
|
||||
<filter id="pac-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#92400e" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Top jaw -->
|
||||
<path class="pac-mouth-top"
|
||||
d="M50,50 L95,30 A48,48 0 0,0 2,50 Z"
|
||||
fill="url(#pac-grad)" filter="url(#pac-shadow)"/>
|
||||
<!-- Bottom jaw -->
|
||||
<path class="pac-mouth-bot"
|
||||
d="M50,50 L95,70 A48,48 0 0,1 2,50 Z"
|
||||
fill="url(#pac-grad)" filter="url(#pac-shadow)"/>
|
||||
<!-- Eye -->
|
||||
<circle cx="42" cy="28" r="6" fill="#1e1b4b" opacity="0.9"/>
|
||||
<circle cx="44" cy="26" r="2.2" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
|
||||
export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath, simulatorScanner }: SimulatorLayersProps) {
|
||||
|
||||
const isDarkStyleRef = useRef(isDarkStyle);
|
||||
isDarkStyleRef.current = isDarkStyle;
|
||||
|
||||
const simulatorDataRef = useRef(simulatorData);
|
||||
simulatorDataRef.current = simulatorData;
|
||||
|
||||
const simulatorPathRef = useRef(simulatorPath);
|
||||
simulatorPathRef.current = simulatorPath;
|
||||
|
||||
// DOM marker ref for the scanner (renders ABOVE all map layers + pins)
|
||||
const scannerMarkerRef = useRef<maplibregl.Marker | null>(null);
|
||||
const scannerElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Add Sources and Layers (grid + path only — scanner is a DOM marker now)
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
injectScannerCSS();
|
||||
|
||||
const setupMapLayers = () => {
|
||||
if (!map.getStyle()) return;
|
||||
|
||||
const currentIsDark = isDarkStyleRef.current;
|
||||
|
||||
if (!map.getSource('simulator-grid')) {
|
||||
map.addSource('simulator-grid', { type: 'geojson', data: simulatorDataRef.current || emptyFc as any });
|
||||
}
|
||||
if (!map.getLayer('simulator-grid-fill')) {
|
||||
map.addLayer({
|
||||
id: 'simulator-grid-fill',
|
||||
type: 'fill',
|
||||
source: 'simulator-grid',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'match', ['get', 'sim_status'],
|
||||
'pending', currentIsDark ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
|
||||
'skipped', currentIsDark ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
|
||||
'processed', currentIsDark ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
|
||||
'rgba(0,0,0,0)'
|
||||
],
|
||||
'fill-outline-color': currentIsDark ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getSource('simulator-path')) {
|
||||
map.addSource('simulator-path', { type: 'geojson', data: simulatorPathRef.current || emptyFc as any });
|
||||
}
|
||||
if (!map.getLayer('simulator-path-line')) {
|
||||
map.addLayer({
|
||||
id: 'simulator-path-line',
|
||||
type: 'line',
|
||||
source: 'simulator-path',
|
||||
paint: {
|
||||
'line-color': 'rgba(59, 130, 246, 0.4)',
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [3, 3]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (map.getStyle()) setupMapLayers();
|
||||
map.on('styledata', setupMapLayers);
|
||||
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.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');
|
||||
if (map.getSource('simulator-path')) map.removeSource('simulator-path');
|
||||
}
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Update Styles when theme changes
|
||||
useEffect(() => {
|
||||
if (!map || !map.getStyle()) return;
|
||||
if (map.getLayer('simulator-grid-fill')) {
|
||||
map.setPaintProperty('simulator-grid-fill', 'fill-color', [
|
||||
'match', ['get', 'sim_status'],
|
||||
'pending', isDarkStyle ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
|
||||
'skipped', isDarkStyle ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
|
||||
'processed', isDarkStyle ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
|
||||
'rgba(0,0,0,0)'
|
||||
]);
|
||||
map.setPaintProperty('simulator-grid-fill', 'fill-outline-color', isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)');
|
||||
}
|
||||
}, [map, isDarkStyle]);
|
||||
|
||||
// Update grid + path data
|
||||
useEffect(() => {
|
||||
if (!map) 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);
|
||||
} catch (e) {
|
||||
console.warn("Could not update simulator data", e);
|
||||
}
|
||||
}, [map, simulatorData, simulatorPath]);
|
||||
|
||||
// Update pacman DOM marker position + rotation
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const fc = simulatorScanner as any;
|
||||
const feature = fc?.features?.[0];
|
||||
|
||||
if (!feature?.geometry?.coordinates) {
|
||||
if (scannerMarkerRef.current) {
|
||||
scannerMarkerRef.current.remove();
|
||||
scannerMarkerRef.current = null;
|
||||
scannerElementRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const bearing = feature.properties?.bearing ?? 0;
|
||||
|
||||
if (!scannerMarkerRef.current) {
|
||||
const el = createPacmanElement();
|
||||
el.style.zIndex = '9999';
|
||||
scannerElementRef.current = el;
|
||||
|
||||
scannerMarkerRef.current = new maplibregl.Marker({
|
||||
element: el,
|
||||
anchor: 'center',
|
||||
})
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(map);
|
||||
|
||||
// Also ensure the MapLibre marker container stays on top
|
||||
const markerEl = scannerMarkerRef.current.getElement();
|
||||
if (markerEl) markerEl.style.zIndex = '9999';
|
||||
|
||||
// Force the MapLibre marker wrapper above all other markers
|
||||
const wrapper = scannerMarkerRef.current.getElement()?.parentElement;
|
||||
if (wrapper) wrapper.style.zIndex = '9999';
|
||||
} else {
|
||||
scannerMarkerRef.current.setLngLat([lng, lat]);
|
||||
}
|
||||
|
||||
// Rotate the whole pacman body to face bearing direction
|
||||
if (scannerElementRef.current) {
|
||||
const body = scannerElementRef.current.querySelector('.sim-pacman-body') as HTMLElement;
|
||||
if (body) {
|
||||
body.style.transform = `translate(-50%, -50%) rotate(${bearing}deg)`;
|
||||
}
|
||||
// Rotate dots opposite so they trail behind
|
||||
const dots = scannerElementRef.current.querySelector('.sim-pacman-dots') as HTMLElement;
|
||||
if (dots) {
|
||||
dots.style.transform = `rotate(${bearing}deg)`;
|
||||
}
|
||||
}
|
||||
}, [map, simulatorScanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,3 +1,24 @@
|
||||
// 2D base style — no terrain, no hillshade. Used as default to avoid
|
||||
// redundant DEM tile fetches on every viewport change.
|
||||
export const MAP_STYLE_OSM_2D = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster' as const,
|
||||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap Contributors',
|
||||
maxzoom: 19
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{ id: 'osm', type: 'raster' as const, source: 'osm' }
|
||||
]
|
||||
};
|
||||
|
||||
// 3D style — terrain + hillshade. Only used when user opts in.
|
||||
// NOTE: previously had TWO sources (terrainSource + hillshadeSource) pointing
|
||||
// to the same MapTiler URL, causing double tile fetches. Now merged into one.
|
||||
export const MAP_STYLE_OSM_3D = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
@ -12,17 +33,12 @@ export const MAP_STYLE_OSM_3D = {
|
||||
type: 'raster-dem' as const,
|
||||
url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9',
|
||||
tileSize: 256
|
||||
},
|
||||
hillshadeSource: {
|
||||
type: 'raster-dem' as const,
|
||||
url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9',
|
||||
tileSize: 256
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{ id: 'osm', type: 'raster' as const, source: 'osm' },
|
||||
{
|
||||
id: 'hills', type: 'hillshade' as const, source: 'hillshadeSource',
|
||||
id: 'hills', type: 'hillshade' as const, source: 'terrainSource',
|
||||
layout: { visibility: 'visible' as const },
|
||||
paint: { 'hillshade-shadow-color': '#473B24' }
|
||||
}
|
||||
@ -32,9 +48,10 @@ export const MAP_STYLE_OSM_3D = {
|
||||
};
|
||||
|
||||
export const MAP_STYLES = {
|
||||
light: MAP_STYLE_OSM_3D,
|
||||
light: MAP_STYLE_OSM_2D,
|
||||
'3d': MAP_STYLE_OSM_3D,
|
||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
osm_raster: MAP_STYLE_OSM_3D,
|
||||
osm_raster: MAP_STYLE_OSM_2D,
|
||||
vector_light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
||||
};
|
||||
|
||||
|
||||
@ -123,21 +123,12 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar Toggle Button */}
|
||||
{!isSharedView && (
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="absolute top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 16}px)` : 'translateX(0)' }}
|
||||
>
|
||||
{isSidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden">
|
||||
{selectedJobId ? (
|
||||
<JobViewer key={selectedJobId} jobId={selectedJobId} />
|
||||
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} />
|
||||
) : (
|
||||
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette } from 'lucide-react';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
|
||||
import { CompetitorsGridView } from '../CompetitorsGridView';
|
||||
import { CompetitorsMapView } from '../CompetitorsMapView';
|
||||
@ -34,6 +34,8 @@ interface GridSearchResultsProps {
|
||||
isOwner?: boolean;
|
||||
isPublic?: boolean;
|
||||
onTogglePublic?: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
const MOCK_SETTINGS = {
|
||||
@ -43,7 +45,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 }: GridSearchResultsProps) {
|
||||
export function GridSearchResults({ 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()));
|
||||
@ -127,13 +129,47 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
const dummyEnrich = async () => { };
|
||||
const handleMapCenterUpdate = () => { };
|
||||
|
||||
// Debounced map move handler — prevents setSearchParams from triggering
|
||||
// a full React re-render on every single moveend event
|
||||
const mapMoveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleMapMove = useCallback((state: { lat: number; lng: number; zoom: number; pitch?: number; bearing?: number }) => {
|
||||
if (mapMoveTimerRef.current) {
|
||||
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 });
|
||||
}, 300);
|
||||
}, [setSearchParams]);
|
||||
|
||||
const settings = { ...MOCK_SETTINGS, excluded_types: excludedTypes };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden w-full relative">
|
||||
<div className="flex justify-between mb-4 mt-2 px-2">
|
||||
{/* Expand button — only visible when fresh regions are picked */}
|
||||
<div className="flex justify-between items-center mb-4 mt-2 px-2">
|
||||
{/* Left: sidebar toggle + expand button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-1.5 rounded-md transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={isSidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
>
|
||||
{isSidebarOpen ? <PanelLeftClose className="h-4 w-4" /> : <PanelLeftOpen className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && freshRegions.length > 0 && (
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
@ -172,53 +208,60 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => handleViewChange('grid')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'grid' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Grid View"
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'grid' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="List View"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">List</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('thumb')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'thumb' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'thumb' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Thumbnail View"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Grid</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('map')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'map' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'map' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Map View"
|
||||
>
|
||||
<MapIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Map</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('poster')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'poster' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'poster' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Poster Map View"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Poster</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('meta')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'meta' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'meta' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Meta Analysis"
|
||||
>
|
||||
<PieChart className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Meta</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewChange('report')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'report' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'report' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Markdown Report"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Report</span>
|
||||
</button>
|
||||
{import.meta.env.DEV && sseLogs && (
|
||||
<button
|
||||
onClick={() => handleViewChange('log')}
|
||||
className={`p-1.5 rounded-md transition-all ${viewMode === 'log' ? 'bg-amber-50 text-amber-600 dark:bg-amber-900/50 dark:text-amber-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'log' ? 'bg-amber-50 text-amber-600 dark:bg-amber-900/50 dark:text-amber-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="SSE Log (Dev)"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-xs font-medium">Log</span>
|
||||
</button>
|
||||
)}
|
||||
{viewMode === 'poster' && (
|
||||
@ -243,7 +286,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
|
||||
<button
|
||||
onClick={onTogglePublic}
|
||||
className={`p-1.5 rounded-md transition-all ${isPublic ? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/50 dark:text-emerald-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${isPublic ? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/50 dark:text-emerald-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title={isPublic ? "Public Share Enabled" : "Enable Public Share"}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
@ -316,22 +359,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
const b = parseFloat(searchParams.get('mapBearing') || '');
|
||||
return !isNaN(b) ? b : undefined;
|
||||
})()}
|
||||
onMapMove={(state) => {
|
||||
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));
|
||||
|
||||
// Only force 'map' view if we aren't already in 'poster' mode.
|
||||
if (prev.get('view') !== 'poster') {
|
||||
newParams.set('view', 'map');
|
||||
}
|
||||
return newParams;
|
||||
}, { replace: true });
|
||||
}}
|
||||
onMapMove={handleMapMove}
|
||||
onClosePosterMode={() => handleViewChange('map')}
|
||||
onRegionsChange={setPickedRegions}
|
||||
simulatorSettings={mapSimSettings}
|
||||
|
||||
@ -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 }: any) {
|
||||
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: any) {
|
||||
const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream();
|
||||
|
||||
return (
|
||||
@ -30,11 +30,13 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onEx
|
||||
isOwner={isOwner}
|
||||
isPublic={isPublic}
|
||||
onTogglePublic={onTogglePublic}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobViewer({ jobId }: { jobId: string }) {
|
||||
export function JobViewer({ 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[]>([]);
|
||||
@ -302,6 +304,8 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
isOwner={jobData?.isOwner}
|
||||
isPublic={isSharingTarget}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
</GridSearchStreamProvider>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchPlacesGridSearches, deletePlacesGridSearch, GridSearchSummary } from '../client-gridsearch';
|
||||
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch } from 'lucide-react';
|
||||
import { getCurrentLang } from '../../../i18n';
|
||||
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch, MapPin, Mail } from 'lucide-react';
|
||||
import { getCurrentLang } from '@/i18n';
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
if (!dateString) return '';
|
||||
@ -15,7 +15,7 @@ function getRelativeTime(dateString: string): string {
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHour = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHour / 24);
|
||||
|
||||
|
||||
if (Math.abs(diffDay) >= 7) {
|
||||
return date.toLocaleDateString(lang, { month: 'short', day: 'numeric' });
|
||||
} else if (Math.abs(diffDay) >= 1) {
|
||||
@ -71,17 +71,24 @@ const SearchRow = ({
|
||||
<StatusIcon status={search.status} isSelected={isSelected} />
|
||||
<div className="min-w-0 pr-2">
|
||||
<h4 className={`font-medium truncate ${isChild ? 'text-xs' : 'text-sm'} ${isSelected ? 'text-indigo-900 dark:text-indigo-300' : 'text-gray-900 dark:text-gray-100'}`} title={search.regionName}>
|
||||
{search.regionName} {!isChild && <span className="text-xs font-normal opacity-70">({search.level})</span>}
|
||||
<span className="font-bold text-sm capitalize">{search.query}</span> @ {search.regionName} {!isChild && <span className="text-xs font-normal opacity-70">({search.level})</span>}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-0.5">
|
||||
{!isChild && search.countries && search.countries.length > 0 && !search.countries.includes(search.regionName) && (
|
||||
<span className="text-[10px] uppercase font-bold text-gray-400 dark:text-gray-500 tracking-wider whitespace-nowrap">
|
||||
{search.countries.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<p className={`text-gray-500 dark:text-gray-400 truncate max-w-[200px] ${isChild ? 'text-[10px]' : 'text-xs'}`} title={search.query}>
|
||||
<p className={`text-gray-500 dark:text-gray-400 truncate max-w-[140px] ${isChild ? 'text-[10px]' : 'text-xs'}`} title={search.query}>
|
||||
{search.query}
|
||||
</p>
|
||||
{(search.results != null && search.results > 0) && (
|
||||
<span className="flex items-center text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap" title="Total results">
|
||||
<MapPin className="w-3 h-3 mr-0.5 inline" />
|
||||
{search.results}
|
||||
</span>
|
||||
)}
|
||||
{(search.emails != null && search.emails > 0) && (
|
||||
<span className="flex items-center text-[10px] text-emerald-500 dark:text-emerald-400 whitespace-nowrap" title="Emails found">
|
||||
<Mail className="w-3 h-3 mr-0.5 inline" />
|
||||
{search.emails}
|
||||
</span>
|
||||
)}
|
||||
{search.generatedAt && (
|
||||
<span className="flex items-center text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
<Clock className="w-3 h-3 mr-1 inline" />
|
||||
@ -89,6 +96,19 @@ const SearchRow = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isChild && (() => {
|
||||
const crumbs = search.hierarchy && search.hierarchy.length > 0
|
||||
? search.hierarchy.filter(h => h !== search.regionName)
|
||||
: (search.countries && search.countries.length > 0 && !search.countries.includes(search.regionName)
|
||||
? search.countries
|
||||
: []);
|
||||
if (crumbs.length === 0) return null;
|
||||
return (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 truncate mt-0.5" title={crumbs.join(' › ')}>
|
||||
{crumbs.join(' › ')}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0 space-x-1">
|
||||
@ -97,11 +117,10 @@ const SearchRow = ({
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
isChild
|
||||
? 'text-gray-300 hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
: 'text-gray-400 hover:text-red-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
className={`p-1.5 rounded-md transition-colors ${isChild
|
||||
? 'text-gray-300 hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
: 'text-gray-400 hover:text-red-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Delete Search"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
|
||||
@ -335,14 +335,19 @@ export function useGridSimulatorState({
|
||||
if (currentIndex > 0 && currentIndex <= activeCells.length) {
|
||||
const currentCell = activeCells[currentIndex - 1];
|
||||
const centroid = turf.centroid(currentCell);
|
||||
|
||||
// Always face the NEXT waypoint (forward-looking bearing)
|
||||
let angle = 0;
|
||||
if (currentIndex > 1) {
|
||||
if (currentIndex < activeCells.length) {
|
||||
// Next cell exists — face toward it
|
||||
const nextCell = activeCells[currentIndex];
|
||||
angle = turf.bearing(centroid, turf.centroid(nextCell));
|
||||
} else if (currentIndex > 1) {
|
||||
// Last cell — keep facing the same direction as the last move
|
||||
const prevCell = activeCells[currentIndex - 2];
|
||||
angle = turf.bearing(turf.centroid(prevCell), centroid);
|
||||
} else if (currentIndex === 1 && activeCells.length > 1) {
|
||||
const nextCell = activeCells[1];
|
||||
angle = turf.bearing(centroid, turf.centroid(nextCell));
|
||||
}
|
||||
|
||||
centroid.properties = {
|
||||
bearing: angle,
|
||||
icon_state: (progressIndex % 2 === 0) ? 'pacman-open' : 'pacman-closed'
|
||||
|
||||
@ -67,7 +67,7 @@ export function useGridSearchState() {
|
||||
|
||||
const includeStats = getPersistentToggle('stats', searchParams.get('stats'), true);
|
||||
const showDensity = getPersistentToggle('density', searchParams.get('density'), false);
|
||||
const showCenters = getPersistentToggle('centers', searchParams.get('centers'), false);
|
||||
const showCenters = getPersistentToggle('centers', searchParams.get('centers'), true);
|
||||
const gadmPickerActive = getPersistentToggle('picker', searchParams.get('picker'), false);
|
||||
const urlStyle = getPersistentString('style', searchParams.get('style'));
|
||||
const posterMode = getPersistentToggle('poster', searchParams.get('poster'), false);
|
||||
|
||||
@ -11,11 +11,15 @@ 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,
|
||||
@ -25,12 +29,24 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
|
||||
});
|
||||
};
|
||||
|
||||
// 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 c = map.getCenter();
|
||||
const z = map.getZoom();
|
||||
const p = map.getPitch();
|
||||
const b = 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)}`);
|
||||
}
|
||||
|
||||
if (onMapMove) {
|
||||
onMapMove({ lat: c.lat, lng: c.lng, zoom: z, pitch: p, bearing: b });
|
||||
}
|
||||
@ -56,7 +72,6 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
|
||||
};
|
||||
|
||||
map.on('move', updateInternals);
|
||||
map.on('zoom', updateInternals);
|
||||
map.on('moveend', handleMoveEnd);
|
||||
|
||||
// Initial set
|
||||
@ -64,8 +79,8 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
|
||||
|
||||
return () => {
|
||||
map.off('move', updateInternals);
|
||||
map.off('zoom', updateInternals);
|
||||
map.off('moveend', handleMoveEnd);
|
||||
if (_moveTimer) clearInterval(_moveTimer);
|
||||
};
|
||||
}, [onMapCenterUpdate]);
|
||||
|
||||
|
||||
@ -275,8 +275,11 @@ export const POSTER_THEMES: Record<string, PosterTheme> = {
|
||||
export function applyPosterTheme(map: maplibregl.Map | any, theme: PosterTheme, isDark: boolean = false) {
|
||||
if (!map || !map.getStyle()) return;
|
||||
|
||||
const hasLayer = (id: string) => map.getStyle() && map.getLayer(id);
|
||||
const hasSource = (id: string) => map.getStyle() && map.getSource(id);
|
||||
|
||||
const setPaintSafe = (layer: string, prop: string, val: string | number) => {
|
||||
if (map.getLayer(layer)) {
|
||||
if (hasLayer(layer)) {
|
||||
try {
|
||||
map.setPaintProperty(layer, prop, val);
|
||||
} catch (e) {
|
||||
@ -353,7 +356,7 @@ export function applyPosterTheme(map: maplibregl.Map | any, theme: PosterTheme,
|
||||
});
|
||||
|
||||
// Make sure we update the GADM borders manually if they exist
|
||||
if (map.getLayer('poster-gadm-borders')) {
|
||||
if (hasLayer('poster-gadm-borders')) {
|
||||
map.setPaintProperty('poster-gadm-borders', 'line-color', theme.text);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user