map ole :)
This commit is contained in:
parent
2d59f5df14
commit
0c0bb53915
@ -238,8 +238,8 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
}, [searchParams, columns, columnWidths]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" style={{ width: '100%' }}>
|
||||
<div className="overflow-hidden" style={{ height: 600, width: '100%' }}>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex-1 min-h-0 w-full overflow-hidden">
|
||||
<MuiThemeProvider theme={muiTheme}>
|
||||
<DataGrid
|
||||
rows={filteredCompetitors}
|
||||
|
||||
@ -20,6 +20,7 @@ import { RegionLayers } from './components/map-layers/RegionLayers';
|
||||
import { MapLayerToggles } from './components/MapLayerToggles';
|
||||
import { MapOverlayToolbars } from './components/MapOverlayToolbars';
|
||||
import { LiveSearchLayers } from './components/map-layers/LiveSearchLayers';
|
||||
import { MapPosterOverlay } from './components/MapPosterOverlay';
|
||||
import { T, translate } from '../../i18n';
|
||||
|
||||
const safeSetStyle = (m: maplibregl.Map, style: any) => {
|
||||
@ -89,7 +90,9 @@ interface CompetitorsMapViewProps {
|
||||
onMapCenterUpdate: (loc: string, zoom?: number) => void;
|
||||
initialCenter?: { lat: number, lng: number };
|
||||
initialZoom?: number;
|
||||
onMapMove?: (state: { lat: number, lng: number, zoom: number }) => void;
|
||||
initialPitch?: number;
|
||||
initialBearing?: number;
|
||||
onMapMove?: (state: { lat: number, lng: number, zoom: number, pitch?: number, bearing?: number }) => void;
|
||||
enrich: (ids: string[], enrichers: string[]) => Promise<void>;
|
||||
isEnriching: boolean;
|
||||
enrichmentProgress?: { current: number; total: number; message: string } | null;
|
||||
@ -102,6 +105,10 @@ interface CompetitorsMapViewProps {
|
||||
liveNodes?: any[];
|
||||
liveScanner?: any;
|
||||
onRegionsChange?: (regions: any[]) => void;
|
||||
isPosterMode?: boolean;
|
||||
onClosePosterMode?: () => void;
|
||||
posterTheme?: string;
|
||||
setPosterTheme?: (theme: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@ -173,13 +180,16 @@ const renderPopupHtml = (competitor: CompetitorFull) => {
|
||||
`;
|
||||
};
|
||||
|
||||
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange }) => {
|
||||
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme }) => {
|
||||
const features: MapFeatures = useMemo(() => {
|
||||
if (isPosterMode) {
|
||||
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false };
|
||||
}
|
||||
return {
|
||||
...MAP_PRESETS[preset],
|
||||
...customFeatures
|
||||
};
|
||||
}, [preset, customFeatures]);
|
||||
}, [preset, customFeatures, isPosterMode]);
|
||||
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const mapWrapper = useRef<HTMLDivElement>(null);
|
||||
@ -223,6 +233,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
const [leftSidebarWidth, setLeftSidebarWidth] = useState(384);
|
||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||
|
||||
const [localPosterTheme, setLocalPosterTheme] = useState('terracotta');
|
||||
const posterTheme = controlledPosterTheme ?? localPosterTheme;
|
||||
const setPosterTheme = setControlledPosterTheme ?? setLocalPosterTheme;
|
||||
|
||||
// Info Panel State
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
|
||||
@ -259,7 +273,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
// Fit map to loaded region boundaries (waits for map readiness)
|
||||
const hasFittedBoundsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasFittedBoundsRef.current || pickerPolygons.length === 0) return;
|
||||
if (hasFittedBoundsRef.current || pickerPolygons.length === 0 || initialCenter) return;
|
||||
|
||||
const fitToPolygons = () => {
|
||||
if (!map.current) return false;
|
||||
@ -336,7 +350,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
style: MAP_STYLES[mapStyleKey],
|
||||
center: [initialCenter?.lng ?? 10.45, initialCenter?.lat ?? 51.16], // Default center (Germany roughly) or prop
|
||||
zoom: initialZoom ?? 5,
|
||||
pitch: 0,
|
||||
pitch: initialPitch ?? 0,
|
||||
bearing: initialBearing ?? 0,
|
||||
maxPitch: 85,
|
||||
});
|
||||
|
||||
@ -430,11 +445,15 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
// Sync Theme/Style
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
safeSetStyle(map.current, MAP_STYLES[mapStyleKey]);
|
||||
let targetStyleKey = mapStyleKey;
|
||||
if (isPosterMode && mapStyleKey === 'light') {
|
||||
targetStyleKey = 'vector_light' as MapStyleKey;
|
||||
}
|
||||
safeSetStyle(map.current, MAP_STYLES[targetStyleKey]);
|
||||
// Note: Re-adding sources/layers after style switch would be needed here for production resilience,
|
||||
// but for now we assume style switching might reset them.
|
||||
// A robust solution would re-initialize layers on 'style.load'.
|
||||
}, [mapStyleKey]);
|
||||
}, [mapStyleKey, isPosterMode]);
|
||||
|
||||
|
||||
|
||||
@ -589,21 +608,26 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
{/* Map Panel & Center Layout */}
|
||||
<div className="relative h-full flex flex-col flex-1 transition-all duration-75 min-w-0 bg-white dark:bg-gray-900">
|
||||
{/* Header: Search Region & Tools Overlay */}
|
||||
<MapOverlayToolbars
|
||||
features={features}
|
||||
sidebarOpen={sidebarOpen}
|
||||
gadmPickerActive={gadmPickerActive}
|
||||
simulatorActive={simulatorActive}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
setGadmPickerActive={setGadmPickerActive}
|
||||
setSimulatorActive={setSimulatorActive}
|
||||
infoPanelOpen={infoPanelOpen}
|
||||
setInfoPanelOpen={setInfoPanelOpen}
|
||||
/>
|
||||
{!isPosterMode && (
|
||||
<MapOverlayToolbars
|
||||
features={features}
|
||||
sidebarOpen={sidebarOpen}
|
||||
gadmPickerActive={gadmPickerActive}
|
||||
simulatorActive={simulatorActive}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
setGadmPickerActive={setGadmPickerActive}
|
||||
setSimulatorActive={setSimulatorActive}
|
||||
infoPanelOpen={infoPanelOpen}
|
||||
setInfoPanelOpen={setInfoPanelOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Map Viewport */}
|
||||
<div className="relative flex-1 min-h-0 w-full overflow-hidden">
|
||||
<div ref={mapContainer} className="w-full h-full" />
|
||||
<div
|
||||
ref={mapContainer}
|
||||
className="w-full h-full relative"
|
||||
/>
|
||||
|
||||
{map.current && (
|
||||
<>
|
||||
@ -629,6 +653,16 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
liveRadii={liveRadii}
|
||||
liveNodes={liveNodes}
|
||||
/>
|
||||
{isPosterMode && (
|
||||
<MapPosterOverlay
|
||||
map={map.current}
|
||||
pickerRegions={pickerRegions}
|
||||
pickerPolygons={pickerPolygons}
|
||||
posterTheme={posterTheme}
|
||||
setPosterTheme={setPosterTheme}
|
||||
onClose={onClosePosterMode || (() => {})}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -24,6 +24,8 @@ export interface RestoredRunState {
|
||||
result: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOwner?: boolean;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
areasSearched: number;
|
||||
totalPlaces: number;
|
||||
@ -139,6 +141,13 @@ export const fetchPlacesGridSearchRunState = async (id: string): Promise<Restore
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updatePlacesGridSearchSettings = async (id: string, settings: { is_public?: boolean; shared_with?: string[] }): Promise<{ success: boolean; settings: any }> => {
|
||||
return apiClient<{ success: boolean; settings: any }>(`/api/places/gridsearch/${id}/settings`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
};
|
||||
|
||||
export const submitPlacesGridSearchJob = async (payload: GridSearchJobPayload): Promise<{ message: string; jobId: string }> => {
|
||||
return apiClient<{ message: string; jobId: string }>('/api/places/gridsearch', {
|
||||
method: 'POST',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { POSTER_THEMES } from '../utils/poster-themes';
|
||||
import { POSTER_THEMES, applyPosterTheme } from '../utils/poster-themes';
|
||||
|
||||
// Fallback feather icons if lucide-react unavailable, or just raw SVG
|
||||
const XIcon = ({ className }: { className?: string }) => (
|
||||
@ -96,6 +96,25 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
|
||||
|
||||
const [showGadmBorders, setShowGadmBorders] = useState(true);
|
||||
|
||||
// Apply exact map layer styling based on poster theme
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const _apply = () => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
applyPosterTheme(map, theme, isDark);
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) _apply();
|
||||
|
||||
map.on('style.load', _apply);
|
||||
map.on('styledata', _apply);
|
||||
|
||||
return () => {
|
||||
map.off('style.load', _apply);
|
||||
map.off('styledata', _apply);
|
||||
};
|
||||
}, [map, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !pickerPolygons || pickerPolygons.length === 0) return;
|
||||
|
||||
@ -135,19 +154,8 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
|
||||
<div className="w-full h-32 absolute top-0 left-0" style={{ background: gradientTop }} />
|
||||
|
||||
{/* Controls (pointer events auto) */}
|
||||
<div id="poster-controls" className="absolute top-4 right-4 flex gap-2 pointer-events-auto z-50">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur rounded flex items-center p-1 border border-gray-200 dark:border-gray-700 shadow-sm text-gray-800 dark:text-gray-200">
|
||||
<PaletteIcon className="w-4 h-4 ml-2 mr-1 opacity-50" />
|
||||
<select
|
||||
value={posterTheme}
|
||||
onChange={e => setPosterTheme(e.target.value)}
|
||||
className="bg-transparent text-sm font-medium border-none focus:ring-0 cursor-pointer outline-none px-2 py-1"
|
||||
>
|
||||
{Object.keys(POSTER_THEMES).map(k => (
|
||||
<option key={k} value={k}>{POSTER_THEMES[k].name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div id="poster-controls" className="absolute top-4 left-4 flex gap-2 pointer-events-auto z-50">
|
||||
|
||||
{pickerPolygons && pickerPolygons.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowGadmBorders(!showGadmBorders)}
|
||||
|
||||
@ -34,7 +34,8 @@ export const MAP_STYLE_OSM_3D = {
|
||||
export const MAP_STYLES = {
|
||||
light: 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_3D,
|
||||
vector_light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
||||
};
|
||||
|
||||
export type MapStyleKey = keyof typeof MAP_STYLES;
|
||||
|
||||
@ -4,40 +4,20 @@ import { PlusCircle, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { OngoingSearches } from './OngoingSearches';
|
||||
import { GridSearchWizard } from './GridSearchWizard';
|
||||
import { JobViewer } from './JobViewer';
|
||||
import { RestoredSearchProvider } from './RestoredSearchContext';
|
||||
import { useRestoredSearch, RestoredSearchProvider } from './RestoredSearchContext';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
|
||||
export default function GridSearch() {
|
||||
function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: string | null, setSelectedJobId: (id: string | null, forceView?: string) => void }) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(320);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [initialWizardSettings, setInitialWizardSettings] = useState<any>(null);
|
||||
const [wizardSessionKey, setWizardSessionKey] = useState(0);
|
||||
|
||||
const { setShowGlobalFooter } = useAppStore();
|
||||
const { state: restoredState } = useRestoredSearch();
|
||||
const isSharedView = restoredState !== null && restoredState.run?.isOwner === false;
|
||||
|
||||
useEffect(() => {
|
||||
setShowGlobalFooter(false);
|
||||
return () => setShowGlobalFooter(true);
|
||||
}, [setShowGlobalFooter]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const selectedJobId = searchParams.get('jobId');
|
||||
|
||||
const setSelectedJobId = useCallback((id: string | null, forceView?: string) => {
|
||||
const next = new URLSearchParams(location.search);
|
||||
if (id) {
|
||||
next.set('jobId', id);
|
||||
if (forceView) {
|
||||
next.set('view', forceView);
|
||||
}
|
||||
} else {
|
||||
next.delete('jobId');
|
||||
}
|
||||
navigate(`${location.pathname}?${next.toString()}`, { replace: true });
|
||||
}, [navigate, location.search, location.pathname]);
|
||||
|
||||
const startResizing = useCallback((shouldStart: boolean) => {
|
||||
setIsResizing(shouldStart);
|
||||
@ -98,69 +78,104 @@ export default function GridSearch() {
|
||||
return () => window.removeEventListener('mousemove', handleDrag);
|
||||
}, [isResizing]);
|
||||
|
||||
const [wizardSessionKey, setWizardSessionKey] = useState(0);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-gray-50 dark:bg-gray-900 w-full relative">
|
||||
<div className="flex h-[calc(100vh-64px)] w-full relative">
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative transition-[width] duration-300 ${!isSidebarOpen ? 'overflow-hidden' : ''}`}
|
||||
style={{ width: isSidebarOpen ? `${sidebarWidth}px` : '0px' }}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
|
||||
<button
|
||||
onClick={() => { setSelectedJobId(null); setInitialWizardSettings(null); setWizardSessionKey(k => k + 1); }}
|
||||
className={`w-full flex items-center justify-center p-3 rounded-xl font-medium transition-colors ${!selectedJobId ? 'bg-indigo-600 text-white shadow-md' : 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50'}`}
|
||||
>
|
||||
<PlusCircle className="w-5 h-5 mr-2" />
|
||||
New Search
|
||||
</button>
|
||||
</div>
|
||||
{!isSharedView && (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative transition-[width] duration-300 ${!isSidebarOpen ? 'overflow-hidden' : ''}`}
|
||||
style={{ width: isSidebarOpen ? `${sidebarWidth}px` : '0px' }}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
|
||||
<button
|
||||
onClick={() => { setSelectedJobId(null); setInitialWizardSettings(null); setWizardSessionKey(k => k + 1); }}
|
||||
className={`w-full flex items-center justify-center p-3 rounded-xl font-medium transition-colors ${!selectedJobId ? 'bg-indigo-600 text-white shadow-md' : 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50'}`}
|
||||
>
|
||||
<PlusCircle className="w-5 h-5 mr-2" />
|
||||
New Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar pb-10">
|
||||
<OngoingSearches
|
||||
selectedJobId={selectedJobId}
|
||||
onSelectJob={setSelectedJobId}
|
||||
onSearchDeleted={(deletedId) => {
|
||||
if (selectedJobId === deletedId) {
|
||||
setSelectedJobId(null);
|
||||
setWizardSessionKey(k => k + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar pb-10">
|
||||
<OngoingSearches
|
||||
selectedJobId={selectedJobId}
|
||||
onSelectJob={setSelectedJobId}
|
||||
onSearchDeleted={(deletedId) => {
|
||||
if (selectedJobId === deletedId) {
|
||||
setSelectedJobId(null);
|
||||
setWizardSessionKey(k => k + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Toggle Button */}
|
||||
<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 */}
|
||||
<RestoredSearchProvider key={selectedJobId || 'none'} jobId={selectedJobId}>
|
||||
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden bg-gradient-to-br from-indigo-50/50 via-white to-blue-50/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800">
|
||||
{selectedJobId ? (
|
||||
<JobViewer key={selectedJobId} jobId={selectedJobId} />
|
||||
) : (
|
||||
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
|
||||
{/* Resizer handle */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RestoredSearchProvider>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
) : (
|
||||
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GridSearch() {
|
||||
const { setShowGlobalFooter } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
setShowGlobalFooter(false);
|
||||
return () => setShowGlobalFooter(true);
|
||||
}, [setShowGlobalFooter]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const selectedJobId = searchParams.get('jobId');
|
||||
|
||||
const setSelectedJobId = useCallback((id: string | null, forceView?: string) => {
|
||||
const next = new URLSearchParams(location.search);
|
||||
if (id) {
|
||||
next.set('jobId', id);
|
||||
if (forceView) {
|
||||
next.set('view', forceView);
|
||||
}
|
||||
} else {
|
||||
next.delete('jobId');
|
||||
}
|
||||
navigate(`${location.pathname}?${next.toString()}`, { replace: true });
|
||||
}, [navigate, location.search, location.pathname]);
|
||||
|
||||
return (
|
||||
<RestoredSearchProvider key={selectedJobId || 'none'} jobId={selectedJobId}>
|
||||
<GridSearchLayout selectedJobId={selectedJobId} setSelectedJobId={setSelectedJobId} />
|
||||
</RestoredSearchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2 } from 'lucide-react';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette } from 'lucide-react';
|
||||
|
||||
import { CompetitorsGridView } from '../CompetitorsGridView';
|
||||
import { CompetitorsMapView } from '../CompetitorsMapView';
|
||||
@ -9,12 +9,13 @@ import { CompetitorsMetaView } from '../CompetitorsMetaView';
|
||||
import { CompetitorsReportView } from './CompetitorsReportView';
|
||||
import { useRestoredSearch } from './RestoredSearchContext';
|
||||
import { expandPlacesGridSearch } from '../client-gridsearch';
|
||||
import { POSTER_THEMES } from '../utils/poster-themes';
|
||||
|
||||
import { type CompetitorFull } from '@polymech/shared';
|
||||
import { type LogEntry } from '@/contexts/LogContext';
|
||||
import ChatLogBrowser from '@/components/ChatLogBrowser';
|
||||
|
||||
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log';
|
||||
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster';
|
||||
|
||||
interface GridSearchResultsProps {
|
||||
jobId: string;
|
||||
@ -30,6 +31,9 @@ interface GridSearchResultsProps {
|
||||
statusMessage?: string;
|
||||
sseLogs?: LogEntry[];
|
||||
onExpandSubmitted?: () => void;
|
||||
isOwner?: boolean;
|
||||
isPublic?: boolean;
|
||||
onTogglePublic?: () => void;
|
||||
}
|
||||
|
||||
const MOCK_SETTINGS = {
|
||||
@ -39,7 +43,7 @@ const MOCK_SETTINGS = {
|
||||
auto_enrich: false
|
||||
};
|
||||
|
||||
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted }: GridSearchResultsProps) {
|
||||
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic }: GridSearchResultsProps) {
|
||||
const filteredCompetitors = React.useMemo(() => {
|
||||
if (!excludedTypes || excludedTypes.length === 0) return competitors;
|
||||
const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase()));
|
||||
@ -93,11 +97,23 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const urlView = searchParams.get('view') as ViewMode;
|
||||
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
|
||||
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
|
||||
|
||||
return 'grid';
|
||||
});
|
||||
|
||||
const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta');
|
||||
|
||||
const handleThemeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const theme = e.target.value;
|
||||
setPosterTheme(theme);
|
||||
setSearchParams(prev => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('theme', theme);
|
||||
return newParams;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const handleViewChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setSearchParams(prev => {
|
||||
@ -118,7 +134,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<div className="flex justify-between mb-4 mt-2 px-2">
|
||||
{/* Expand button — only visible when fresh regions are picked */}
|
||||
<div className="flex items-center gap-2">
|
||||
{freshRegions.length > 0 && (
|
||||
{isOwner && freshRegions.length > 0 && (
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
disabled={expanding || streaming}
|
||||
@ -133,7 +149,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
Expand +{freshRegions.length}
|
||||
</button>
|
||||
)}
|
||||
{expandError && (
|
||||
{isOwner && expandError && (
|
||||
<span className="text-xs text-red-500">{expandError}</span>
|
||||
)}
|
||||
</div>
|
||||
@ -175,6 +191,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
>
|
||||
<MapIcon className="h-4 w-4" />
|
||||
</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'}`}
|
||||
title="Poster Map View"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</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'}`}
|
||||
@ -198,11 +221,50 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
<Terminal className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{viewMode === 'poster' && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
|
||||
<div className="flex items-center gap-1.5 px-2 bg-indigo-50/50 dark:bg-indigo-900/30 rounded-md">
|
||||
<Palette className="h-3.5 w-3.5 text-indigo-500" />
|
||||
<select
|
||||
value={posterTheme}
|
||||
onChange={handleThemeChange}
|
||||
className="bg-transparent border-none text-xs font-medium focus:ring-0 cursor-pointer p-0 pr-4 appearance-none outline-none text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{Object.keys(POSTER_THEMES).map(k => (
|
||||
<option key={k} value={k}>{POSTER_THEMES[k].name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isOwner ? (
|
||||
<>
|
||||
<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'}`}
|
||||
title={isPublic ? "Public Share Enabled" : "Enable Public Share"}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : isPublic ? (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
|
||||
<div
|
||||
className="p-1.5 rounded-md flex items-center justify-center text-emerald-600 dark:text-emerald-400 cursor-help"
|
||||
title="Shared View (Read Only)"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
{viewMode === 'grid' && (
|
||||
<CompetitorsGridView
|
||||
competitors={filteredCompetitors}
|
||||
@ -220,10 +282,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'map' && (
|
||||
{(viewMode === 'map' || viewMode === 'poster') && (
|
||||
<CompetitorsMapView
|
||||
preset="SearchView"
|
||||
competitors={filteredCompetitors}
|
||||
isPosterMode={viewMode === 'poster'}
|
||||
posterTheme={posterTheme}
|
||||
setPosterTheme={setPosterTheme}
|
||||
onMapCenterUpdate={handleMapCenterUpdate}
|
||||
enrich={dummyEnrich}
|
||||
isEnriching={false}
|
||||
@ -243,16 +308,31 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
const zoom = parseFloat(searchParams.get('mapZoom') || '');
|
||||
return !isNaN(zoom) ? zoom : undefined;
|
||||
})()}
|
||||
initialPitch={(() => {
|
||||
const p = parseFloat(searchParams.get('mapPitch') || '');
|
||||
return !isNaN(p) ? p : undefined;
|
||||
})()}
|
||||
initialBearing={(() => {
|
||||
const b = parseFloat(searchParams.get('mapBearing') || '');
|
||||
return !isNaN(b) ? b : undefined;
|
||||
})()}
|
||||
onMapMove={(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));
|
||||
newParams.set('view', 'map');
|
||||
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 });
|
||||
}}
|
||||
onClosePosterMode={() => handleViewChange('map')}
|
||||
onRegionsChange={setPickedRegions}
|
||||
simulatorSettings={mapSimSettings}
|
||||
onSimulatorSettingsChange={setMapSimSettings}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2, AlertCircle, Lock, MapPin } from 'lucide-react';
|
||||
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes } from '../client-gridsearch';
|
||||
import { Loader2, AlertCircle, Lock, MapPin, Share2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, updatePlacesGridSearchSettings } from '../client-gridsearch';
|
||||
import { GridSearchResults } from './GridSearchResults';
|
||||
|
||||
import { useRestoredSearch } from './RestoredSearchContext';
|
||||
@ -8,7 +9,7 @@ import { GridSearchStreamProvider, useGridSearchStream } from './GridSearchStrea
|
||||
import CollapsibleSection from '@/components/CollapsibleSection';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted }: any) {
|
||||
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic }: any) {
|
||||
const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream();
|
||||
|
||||
return (
|
||||
@ -26,6 +27,9 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onEx
|
||||
statusMessage={statusMessage}
|
||||
sseLogs={sseLogs}
|
||||
onExpandSubmitted={onExpandSubmitted}
|
||||
isOwner={isOwner}
|
||||
isPublic={isPublic}
|
||||
onTogglePublic={onTogglePublic}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -43,9 +47,11 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const [isSharingTarget, setIsSharingTarget] = useState(false);
|
||||
|
||||
// For CompetitorsGridView props
|
||||
const [excludedTypes, setExcludedTypes] = useState<string[]>([]);
|
||||
|
||||
|
||||
// Load initial exclude types
|
||||
useEffect(() => {
|
||||
getGridSearchExcludeTypes().then(types => setExcludedTypes(types));
|
||||
@ -72,6 +78,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
} else {
|
||||
setCompetitors([]);
|
||||
}
|
||||
setIsSharingTarget(payload?.isPublic === true);
|
||||
} catch (err: any) {
|
||||
if (active) setError(err.message || 'Failed to load data');
|
||||
console.error('Failed to load job data:', err);
|
||||
@ -108,6 +115,25 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePublic = async () => {
|
||||
const newValue = !isSharingTarget;
|
||||
setIsSharingTarget(newValue);
|
||||
try {
|
||||
await updatePlacesGridSearchSettings(jobId, { is_public: newValue });
|
||||
if (newValue) {
|
||||
const url = window.location.href;
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success('Public link enabled and copied to clipboard');
|
||||
} else {
|
||||
toast.success('Public link disabled');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsSharingTarget(!newValue); // revert on failure
|
||||
toast.error('Failed to update sharing settings');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!jobId) return null;
|
||||
|
||||
@ -152,19 +178,21 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
<div className="text-red-500 dark:text-red-400 max-w-lg mx-auto bg-red-50 dark:bg-red-900/50 p-4 rounded-xl font-mono text-sm break-all border border-red-200 dark:border-red-900">
|
||||
{errorMsg}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
className="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-xl transition-colors disabled:opacity-50 inline-flex items-center"
|
||||
>
|
||||
{retrying ? (
|
||||
<><Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <T>Retrying...</T></>
|
||||
) : (
|
||||
<T>Retry Job</T>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{jobData?.isOwner && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
className="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-xl transition-colors disabled:opacity-50 inline-flex items-center"
|
||||
>
|
||||
{retrying ? (
|
||||
<><Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <T>Retrying...</T></>
|
||||
) : (
|
||||
<T>Retry Job</T>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -173,20 +201,16 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
const allResults = jobData?.result?.searchResult?.results || competitors;
|
||||
const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[];
|
||||
return (
|
||||
<div className="flex flex-col p-6 flex-1 w-full box-border overflow-hidden min-h-0">
|
||||
<div className="w-full flex-1 min-h-0 flex flex-col bg-white dark:bg-gray-800 rounded-2xl shadow-xl transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between p-6 pb-2 border-b dark:border-gray-700">
|
||||
<div className="flex flex-col flex-1 w-full overflow-hidden min-h-0 mt-2">
|
||||
<div className="w-full flex-1 min-h-0 flex flex-col rounded-2xl shadow-xl transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between">
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100"><T>Search Results</T></h2>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection
|
||||
title={<T>Search Configuration & Metadata</T>}
|
||||
minimal
|
||||
initiallyOpen={false}
|
||||
storageKey={`gridSearchViewMetaCollapse`}
|
||||
className="mt-2"
|
||||
className=""
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-medium"><T>Job ID:</T></span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
|
||||
@ -275,6 +299,9 @@ export function JobViewer({ jobId }: { jobId: string }) {
|
||||
excludedTypes={excludedTypes}
|
||||
dummyUpdateExcluded={handleUpdateExcluded}
|
||||
onExpandSubmitted={refetch}
|
||||
isOwner={jobData?.isOwner}
|
||||
isPublic={isSharingTarget}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
/>
|
||||
</GridSearchStreamProvider>
|
||||
</div>
|
||||
|
||||
@ -4,29 +4,35 @@ import { fetchReverseGeocode, fetchIpLocation } from '../client-gridsearch';
|
||||
|
||||
export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) => void) {
|
||||
const [currentCenterLabel, setCurrentCenterLabel] = useState<string | null>(null);
|
||||
const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number } | null>(null);
|
||||
const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number, pitch?: number, bearing?: number } | null>(null);
|
||||
const [isLocating, setIsLocating] = useState(false);
|
||||
|
||||
// Optional reference to user's location marker
|
||||
const userLocationMarkerRef = useRef<maplibregl.Marker | null>(null);
|
||||
|
||||
const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number }) => void) => {
|
||||
const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number, pitch?: number, bearing?: number }) => void) => {
|
||||
const updateInternals = () => {
|
||||
const c = map.getCenter();
|
||||
const z = map.getZoom();
|
||||
const p = map.getPitch();
|
||||
const b = map.getBearing();
|
||||
setMapInternals({
|
||||
zoom: Math.round(z * 100) / 100,
|
||||
lat: Math.round(c.lat * 10000) / 10000,
|
||||
lng: Math.round(c.lng * 10000) / 10000
|
||||
lng: Math.round(c.lng * 10000) / 10000,
|
||||
pitch: Math.round(p),
|
||||
bearing: Math.round(b)
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveEnd = async () => {
|
||||
const c = map.getCenter();
|
||||
const z = map.getZoom();
|
||||
const p = map.getPitch();
|
||||
const b = map.getBearing();
|
||||
|
||||
if (onMapMove) {
|
||||
onMapMove({ lat: c.lat, lng: c.lng, zoom: z });
|
||||
onMapMove({ lat: c.lat, lng: c.lng, zoom: z, pitch: p, bearing: b });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -271,3 +271,89 @@ export const POSTER_THEMES: Record<string, PosterTheme> = {
|
||||
"road_default": "#C9BBAA"
|
||||
},
|
||||
};
|
||||
|
||||
export function applyPosterTheme(map: maplibregl.Map | any, theme: PosterTheme, isDark: boolean = false) {
|
||||
if (!map || !map.getStyle()) return;
|
||||
|
||||
const setPaintSafe = (layer: string, prop: string, val: string | number) => {
|
||||
if (map.getLayer(layer)) {
|
||||
try {
|
||||
map.setPaintProperty(layer, prop, val);
|
||||
} catch (e) {
|
||||
// Ignore unsupported paint properties for this layer type
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const layers = map.getStyle().layers || [];
|
||||
|
||||
layers.forEach((layer: any) => {
|
||||
const id = layer.id.toLowerCase();
|
||||
|
||||
// Hide all symbols (labels, POIs, etc)
|
||||
if (layer.type === 'symbol') {
|
||||
map.setLayoutProperty(id, 'visibility', 'none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Background
|
||||
if (id === 'background') {
|
||||
setPaintSafe(id, 'background-color', theme.bg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Water
|
||||
if (id.includes('water') && !id.includes('shadow')) {
|
||||
setPaintSafe(id, 'fill-color', theme.water);
|
||||
setPaintSafe(id, 'line-color', theme.water);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parks/Green/Landcover
|
||||
if (id.includes('park') || id.includes('landcover') || id.includes('green') || id.includes('cemetery') || id.includes('pitch')) {
|
||||
const isWaterBlock = id.includes('water'); // Filter edgecase if landcover_water
|
||||
if (!isWaterBlock) {
|
||||
setPaintSafe(id, 'fill-color', theme.parks);
|
||||
setPaintSafe(id, 'line-color', theme.parks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buildings
|
||||
if (id.includes('building')) {
|
||||
setPaintSafe(id, 'fill-color', theme.road_residential || theme.road_default);
|
||||
setPaintSafe(id, 'line-color', theme.road_residential || theme.road_default);
|
||||
|
||||
// Try wrapping opacity changes safely
|
||||
try { map.setPaintProperty(id, 'fill-opacity', 0.2); } catch(e){}
|
||||
try { map.setPaintProperty(id, 'line-opacity', 0.1); } catch(e){}
|
||||
return;
|
||||
}
|
||||
|
||||
// Roads / Transport Infrastructure
|
||||
if (id.includes('road') || id.includes('highway') || id.includes('tunnel') || id.includes('bridge') || id.includes('aeroway')) {
|
||||
if (id.includes('mot') || id.includes('motorway') || id.includes('trunk') || id.includes('runway')) {
|
||||
setPaintSafe(id, 'line-color', theme.road_motorway);
|
||||
}
|
||||
else if (id.includes('pri') || id.includes('primary') || id.includes('major')) {
|
||||
setPaintSafe(id, 'line-color', theme.road_primary);
|
||||
}
|
||||
else if (id.includes('sec') || id.includes('secondary')) {
|
||||
setPaintSafe(id, 'line-color', theme.road_secondary);
|
||||
}
|
||||
else if (id.includes('ter') || id.includes('tertiary')) {
|
||||
setPaintSafe(id, 'line-color', theme.road_tertiary);
|
||||
}
|
||||
else {
|
||||
// Minor / Residential / Path / Track / Service / Taxiway
|
||||
setPaintSafe(id, 'line-color', theme.road_residential || theme.road_default);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we update the GADM borders manually if they exist
|
||||
if (map.getLayer('poster-gadm-borders')) {
|
||||
map.setPaintProperty('poster-gadm-borders', 'line-color', theme.text);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user