import maplibregl, { type LayerSpecification, type StyleSpecification, type VectorSourceSpecification, } from 'maplibre-gl'; import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; import { getLayerId, getMapTilerKey } from '../lib/mapCore'; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, ]; export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { const oceanSourceId = 'maptiler-ocean'; if (!style.sources) style.sources = {} as StyleSpecification['sources']; if (!style.layers) style.layers = []; if (!style.sources[oceanSourceId]) { style.sources[oceanSourceId] = { type: 'vector', url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`, } satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string]; } const depth = ['to-number', ['get', 'depth']] as unknown as number[]; const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; // Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean const bathyFillColor = [ 'interpolate', ['linear'], depth, -11000, '#00040b', -8000, '#010610', -6000, '#020816', -4000, '#030c1c', -2000, '#041022', -1000, '#051529', -500, '#061a30', -200, '#071f36', -100, '#08263d', -50, '#0e3d5e', -20, '#145578', -10, '#1a6e8e', 0, '#2097a6', ] as const; const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', source: oceanSourceId, 'source-layer': 'contour', minzoom: 5, maxzoom: 24, paint: { 'fill-color': bathyFillColor, 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], }, } as unknown as LayerSpecification; const bathyBandBorders: LayerSpecification = { id: 'bathymetry-borders', type: 'line', source: oceanSourceId, 'source-layer': 'contour', minzoom: 5, maxzoom: 24, paint: { 'line-color': 'rgba(255,255,255,0.06)', 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22], 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2], 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6], }, } as unknown as LayerSpecification; const bathyLinesMinor: LayerSpecification = { id: 'bathymetry-lines', type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', minzoom: 7, paint: { 'line-color': [ 'interpolate', ['linear'], depth, -11000, 'rgba(255,255,255,0.04)', -6000, 'rgba(255,255,255,0.05)', -2000, 'rgba(255,255,255,0.07)', 0, 'rgba(255,255,255,0.10)', ], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28], 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85], }, } as unknown as LayerSpecification; const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; const bathyMajorDepthFilter: unknown[] = [ 'in', ['to-number', ['get', 'depth']], ['literal', majorDepths], ] as unknown[]; const bathyLinesMajor: LayerSpecification = { id: 'bathymetry-lines-major', type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', minzoom: 7, maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { 'line-color': 'rgba(255,255,255,0.16)', 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34], 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3], }, } as unknown as LayerSpecification; const bathyBandBordersMajor: LayerSpecification = { id: 'bathymetry-borders-major', type: 'line', source: oceanSourceId, 'source-layer': 'contour', minzoom: 3, maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { 'line-color': 'rgba(255,255,255,0.14)', 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26], 'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15], 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85], }, } as unknown as LayerSpecification; const bathyLabels: LayerSpecification = { id: 'bathymetry-labels', type: 'symbol', source: oceanSourceId, 'source-layer': 'contour_line', minzoom: 10, filter: bathyMajorDepthFilter as unknown as unknown[], layout: { 'symbol-placement': 'line', 'text-field': depthLabel, 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15], 'text-allow-overlap': false, 'text-padding': 2, 'text-rotation-alignment': 'map', }, paint: { 'text-color': 'rgba(226,232,240,0.72)', 'text-halo-color': 'rgba(2,6,23,0.82)', 'text-halo-width': 1.0, 'text-halo-blur': 0.6, }, } as unknown as LayerSpecification; const landformLabels: LayerSpecification = { id: 'bathymetry-landforms', type: 'symbol', source: oceanSourceId, 'source-layer': 'landform', minzoom: 8, filter: ['has', 'name'] as unknown as unknown[], layout: { 'text-field': ['get', 'name'] as unknown as unknown[], 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13], 'text-allow-overlap': false, 'text-anchor': 'center', 'text-offset': [0, 0.0], }, paint: { 'text-color': 'rgba(148,163,184,0.70)', 'text-halo-color': 'rgba(2,6,23,0.85)', 'text-halo-width': 1.0, 'text-halo-blur': 0.7, }, } as unknown as LayerSpecification; const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; if (!Array.isArray(style.layers)) { style.layers = layers as unknown as StyleSpecification['layers']; } // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally // with the bathymetry gradient instead of appearing as near-black voids. const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; const SHALLOW_WATER_FILL = '#14606e'; const SHALLOW_WATER_LINE = '#114f5c'; for (const layer of layers) { const id = getLayerId(layer); if (!id) continue; const spec = layer as Record; const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase(); const layerType = String(spec.type ?? ''); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); if (!isWater) continue; const paint = (spec.paint ?? {}) as Record; if (layerType === 'fill') { paint['fill-color'] = SHALLOW_WATER_FILL; spec.paint = paint; } else if (layerType === 'line') { paint['line-color'] = SHALLOW_WATER_LINE; spec.paint = paint; } } const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol'); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; const existingIds = new Set(); for (const layer of layers) { const id = getLayerId(layer); if (id) existingIds.add(id); } const toInsert = [ bathyFill, bathyBandBorders, bathyBandBordersMajor, bathyLinesMinor, bathyLinesMajor, bathyLabels, landformLabels, ].filter((l) => !existingIds.has(l.id)); if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); } export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { if (!map || !map.isStyleLoaded()) return; if (baseMap !== 'enhanced') return; const isGlobe = projection === 'globe'; for (const range of BATHY_ZOOM_RANGES) { if (!map.getLayer(range.id)) continue; const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; try { map.setLayoutProperty(range.id, 'visibility', 'visible'); } catch { // ignore } try { map.setLayerZoomRange(range.id, minzoom, maxzoom); } catch { // ignore } } } export async function resolveInitialMapStyle(signal: AbortSignal): Promise { const key = getMapTilerKey(); if (!key) return '/map/styles/osm-seamark.json'; const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim(); const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`; const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); const json = (await res.json()) as StyleSpecification; injectOceanBathymetryLayers(json, key); return json; } export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise { if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; return resolveInitialMapStyle(signal); }