293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string>();
|
|
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<string | StyleSpecification> {
|
|
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<string | StyleSpecification> {
|
|
if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
|
return resolveInitialMapStyle(signal);
|
|
}
|