From 0c0bb539154dedfee0c5621d00ad9540684ec9f4 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sat, 28 Mar 2026 15:51:46 +0100 Subject: [PATCH] map ole :) --- .../modules/places/CompetitorsGridView.tsx | 4 +- .../src/modules/places/CompetitorsMapView.tsx | 72 ++++++-- .../src/modules/places/client-gridsearch.ts | 9 + .../places/components/MapPosterOverlay.tsx | 36 ++-- .../modules/places/components/map-styles.ts | 3 +- .../modules/places/gridsearch/GridSearch.tsx | 173 ++++++++++-------- .../places/gridsearch/GridSearchResults.tsx | 98 +++++++++- .../modules/places/gridsearch/JobViewer.tsx | 77 +++++--- .../modules/places/hooks/useMapControls.ts | 14 +- .../src/modules/places/utils/poster-themes.ts | 86 +++++++++ 10 files changed, 419 insertions(+), 153 deletions(-) diff --git a/packages/ui/src/modules/places/CompetitorsGridView.tsx b/packages/ui/src/modules/places/CompetitorsGridView.tsx index add58cbf..c24f88d1 100644 --- a/packages/ui/src/modules/places/CompetitorsGridView.tsx +++ b/packages/ui/src/modules/places/CompetitorsGridView.tsx @@ -238,8 +238,8 @@ export const CompetitorsGridView: React.FC = ({ compet }, [searchParams, columns, columnWidths]); return ( -
-
+
+
{ @@ -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; 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 = ({ 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 = ({ 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(null); const mapWrapper = useRef(null); @@ -223,6 +233,10 @@ export const CompetitorsMapView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ competit {/* Map Panel & Center Layout */}
{/* Header: Search Region & Tools Overlay */} - + {!isPosterMode && ( + + )} {/* Map Viewport */}
-
+
{map.current && ( <> @@ -629,6 +653,16 @@ export const CompetitorsMapView: React.FC = ({ competit liveRadii={liveRadii} liveNodes={liveNodes} /> + {isPosterMode && ( + {})} + /> + )} )} diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index 9bb7de7a..a76ed569 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -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 => { + 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', diff --git a/packages/ui/src/modules/places/components/MapPosterOverlay.tsx b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx index 64b272ee..0fcf5298 100644 --- a/packages/ui/src/modules/places/components/MapPosterOverlay.tsx +++ b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx @@ -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
{/* Controls (pointer events auto) */} -
-
- - -
+
+ {pickerPolygons && pickerPolygons.length > 0 && ( -
+ {!isSharedView && ( +
+
+ +
-
- { - if (selectedJobId === deletedId) { - setSelectedJobId(null); - setWizardSessionKey(k => k + 1); - } - }} - /> -
+
+ { + if (selectedJobId === deletedId) { + setSelectedJobId(null); + setWizardSessionKey(k => k + 1); + } + }} + /> +
- {/* Resizer handle */} - {isSidebarOpen && ( -
- )} -
- - {/* Sidebar Toggle Button */} - - - {/* Main Content Area */} - -
- {selectedJobId ? ( - - ) : ( - setSelectedJobId(id, 'map')} /> + {/* Resizer handle */} + {isSidebarOpen && ( +
)}
- + )} + {/* Sidebar Toggle Button */} + {!isSharedView && ( + + )} + + {/* Main Content Area */} +
+ {selectedJobId ? ( + + ) : ( + setSelectedJobId(id, 'map')} /> + )} +
); } + +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 ( + + + + ); +} diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 2db14aad..d92263ae 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -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(() => { 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) => { + 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
{/* Expand button — only visible when fresh regions are picked */}
- {freshRegions.length > 0 && ( + {isOwner && freshRegions.length > 0 && (
@@ -175,6 +191,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc > + )} + {viewMode === 'poster' && ( + <> +
+
+ + +
+ + )} + {isOwner ? ( + <> +
+ + + ) : isPublic ? ( + <> +
+
+ +
+ + ) : null}
-
+
{viewMode === 'grid' && ( )} - {viewMode === 'map' && ( + {(viewMode === 'map' || viewMode === 'poster') && ( { + 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} diff --git a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx index 38bda694..2bc41849 100644 --- a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx +++ b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx @@ -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([]); - + // 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 }) {
{errorMsg}
-
- -
+ {jobData?.isOwner && ( +
+ +
+ )}
); @@ -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 ( -
-
-
+
+
+
-
-

Search Results

-
- Search Configuration & Metadata} minimal initiallyOpen={false} storageKey={`gridSearchViewMetaCollapse`} - className="mt-2" + className="" >

Job ID: {jobId}

@@ -275,6 +299,9 @@ export function JobViewer({ jobId }: { jobId: string }) { excludedTypes={excludedTypes} dummyUpdateExcluded={handleUpdateExcluded} onExpandSubmitted={refetch} + isOwner={jobData?.isOwner} + isPublic={isSharingTarget} + onTogglePublic={handleTogglePublic} />
diff --git a/packages/ui/src/modules/places/hooks/useMapControls.ts b/packages/ui/src/modules/places/hooks/useMapControls.ts index d11b5d5a..ba10541a 100644 --- a/packages/ui/src/modules/places/hooks/useMapControls.ts +++ b/packages/ui/src/modules/places/hooks/useMapControls.ts @@ -4,29 +4,35 @@ import { fetchReverseGeocode, fetchIpLocation } from '../client-gridsearch'; export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) => void) { const [currentCenterLabel, setCurrentCenterLabel] = useState(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(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 { diff --git a/packages/ui/src/modules/places/utils/poster-themes.ts b/packages/ui/src/modules/places/utils/poster-themes.ts index 41b466fc..8d222cf8 100644 --- a/packages/ui/src/modules/places/utils/poster-themes.ts +++ b/packages/ui/src/modules/places/utils/poster-themes.ts @@ -271,3 +271,89 @@ export const POSTER_THEMES: Record = { "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); + } +}