gc-wing/apps/web/src/widgets/map3d/layers/bathymetry.ts
htlee cd60f553ee feat(map): 선박 외곽선 대비 및 줌 스케일링 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:01:11 +09:00

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