map | list : speed & nav

This commit is contained in:
lovebird 2026-03-29 14:43:15 +02:00
parent 990475bcf3
commit 708a756a07
17 changed files with 892 additions and 300 deletions

View File

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

View File

@ -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}
/>

View File

@ -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")}

View File

@ -11,6 +11,9 @@ export interface GridSearchSummary {
level: string;
query: string;
countries?: string[];
hierarchy?: string[];
results?: number;
emails?: number;
generatedAt: string;
children?: GridSearchSummary[];
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '&copy; 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'
};

View File

@ -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')} />
)}

View File

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

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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