perf(map): 줌 기반 LOD + 심해 등심선 제거

- applyLandLayerLOD: 베이스맵 육지 레이어에 minzoom 적용
  (landcover z9, transportation z8, building z14 등)
- 수심 3-tier LOD: coarse(z3-7), medium(z7-9), detail(z9+)
- shallowFilter: depth >= -2000, 심해 feature GPU 미전달
- applyDepthGradient ascending order 에러 수정
- vertex 경고 passthrough (디버깅용 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-16 15:15:31 +09:00
부모 d5700ba587
커밋 b022e4bc36
4개의 변경된 파일167개의 추가작업 그리고 72개의 파일을 삭제

파일 보기

@ -110,7 +110,7 @@ export function useBaseMapToggle(
if (!map) return;
if (showSeamark) {
try {
ensureSeamarkOverlay(map, 'bathymetry-lines');
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
} catch {
// ignore until style is ready

파일 보기

@ -103,7 +103,15 @@ export function useMapInit(
// globe 모드에서 scrollZoom의 easeTo around 경고 억제
// eslint-disable-next-line no-console
console.warn = function (...args: unknown[]) {
if (typeof args[0] === 'string' && (args[0] as string).includes('Easing around a point')) return;
if (typeof args[0] === 'string') {
const msg = args[0] as string;
if (msg.includes('Easing around a point')) return;
// vertex 경고는 디버그용으로 1회만 출력 후 억제
if (msg.includes('Max vertices per segment')) {
origWarn.apply(console, args as [unknown, ...unknown[]]);
return;
}
}
origWarn.apply(console, args as [unknown, ...unknown[]]);
};
try {
@ -177,7 +185,7 @@ export function useMapInit(
}
if (!showSeamarkRef.current) return;
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch {
// ignore
}
@ -203,7 +211,7 @@ export function useMapInit(
map.once('load', () => {
if (showSeamarkRef.current) {
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch {
// ignore
}

파일 보기

@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
if (layer.type !== 'symbol') continue;
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout?.['text-field']) continue;
if (layer.id === 'bathymetry-labels') continue;
if (layer.id.startsWith('bathymetry-labels')) continue;
const textField =
lang === 'local'
? ['get', 'name']
@ -105,14 +105,16 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
if (!map.getLayer('bathymetry-fill')) return;
const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length === 0) return;
const expr: unknown[] = ['interpolate', ['linear'], depth];
const deepest = sorted[0];
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
for (const s of sorted) {
expr.push(s.depth, s.color);
}
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
const shallowest = sorted[sorted.length - 1];
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
if (shallowest.depth < 0) {
expr.push(0, lightenHex(shallowest.color, 1.8));
}
try {
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
} catch {
@ -122,7 +124,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
const expr = DEPTH_FONT_SIZE_MAP[size];
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setLayoutProperty(layerId, 'text-size', expr);
@ -133,7 +135,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
}
function applyDepthFontColor(map: maplibregl.Map, color: string) {
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setPaintProperty(layerId, 'text-color', color);

파일 보기

@ -11,10 +11,53 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
{ id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] },
{ id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] },
{ id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] },
{ id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] },
{ id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] },
{ id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] },
];
/**
* LOD vertex가 minzoom을
* , .
* .
*/
function applyLandLayerLOD(style: StyleSpecification): void {
if (!style.layers || !Array.isArray(style.layers)) return;
// source-layer → 렌더링을 시작할 최소 줌 레벨
// globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지
const LOD_MINZOOM: Record<string, number> = {
'landcover': 9,
'globallandcover': 9,
'landuse': 11,
'boundary': 5,
'transportation': 8,
'transportation_name': 10,
'building': 14,
'housenumber': 16,
'aeroway': 11,
'park': 10,
'mountain_peak': 11,
};
for (const layer of style.layers as unknown as LayerSpecification[]) {
const spec = layer as Record<string, unknown>;
const sourceLayer = spec['source-layer'] as string | undefined;
if (!sourceLayer) continue;
const lodMin = LOD_MINZOOM[sourceLayer];
if (lodMin === undefined) continue;
// 기존 minzoom보다 높을 때만 덮어씀
const current = (spec.minzoom as number) ?? 0;
if (lodMin > current) {
spec.minzoom = lodMin;
}
}
}
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = 'maptiler-ocean';
@ -31,19 +74,11 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
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
// 수심 색상: -2000m에서 절단 — 심해는 베이스 수색과 동일하게 처리
const bathyFillColor = [
'interpolate',
['linear'],
depth,
-11000,
'#00040b',
-8000,
'#010610',
-6000,
'#020816',
-4000,
'#030c1c',
-2000,
'#041022',
-1000,
@ -64,6 +99,17 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
'#2097a6',
] as const;
// depth >= -2000 필터: -2000m보다 깊은 등심선은 GPU에 전달하지 않음
const shallowFilter = ['>=', depth, -2000] as unknown[];
// --- Depth tiers for zoom-based LOD ---
const DEPTHS_COARSE = [-1000, -2000];
const DEPTHS_MEDIUM = [-100, -500, -1000, -2000];
const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000];
const depthIn = (depths: number[]) =>
['all', shallowFilter, ['in', depth, ['literal', depths]]] as unknown[];
// === Fill (contour polygons) — 단일 레이어, shallowFilter만 적용 ===
const bathyFill: LayerSpecification = {
id: 'bathymetry-fill',
type: 'fill',
@ -71,104 +117,140 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
'source-layer': 'contour',
minzoom: 3,
maxzoom: 24,
filter: shallowFilter as unknown as unknown[],
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 = {
// === Borders (contour polygon edges) — 2-tier LOD ===
// z3-z7: 1000m, 2000m 경계만
const bathyBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
},
} as unknown as LayerSpecification;
// z7+: 전체 shallow 등심선 경계
const bathyBorders: LayerSpecification = {
id: 'bathymetry-borders',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 5, // fill은 3부터, borders는 5부터
minzoom: 7,
maxzoom: 24,
filter: shallowFilter as unknown as unknown[],
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],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
},
} as unknown as LayerSpecification;
const bathyLinesMinor: LayerSpecification = {
id: 'bathymetry-lines',
// === Contour lines (contour_line) — 3-tier LOD ===
// z5-z7: 1000m, 2000m만
const bathyLinesCoarse: LayerSpecification = {
id: 'bathymetry-lines-coarse',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
minzoom: 5,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
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],
'line-color': 'rgba(255,255,255,0.12)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
'line-blur': 0.5,
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
},
} 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[];
// z7-z9: 100, 500, 1000, 2000m
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[],
maxzoom: 9,
filter: depthIn(DEPTHS_MEDIUM) 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],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
},
} as unknown as LayerSpecification;
const bathyBandBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
// z9+: 50, 100, 200, 500, 1000, 2000m (풀 디테일)
const bathyLinesDetail: LayerSpecification = {
id: 'bathymetry-lines-detail',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
'source-layer': 'contour_line',
minzoom: 9,
maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[],
filter: depthIn(DEPTHS_DETAIL) 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],
'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
},
} as unknown as LayerSpecification;
// === Labels — 2-tier LOD ===
// z6-z9: 1000m, 2000m 라벨만
const bathyLabelsCoarse: LayerSpecification = {
id: 'bathymetry-labels-coarse',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 6,
maxzoom: 9,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(226,232,240,0.78)',
'text-halo-color': 'rgba(2,6,23,0.88)',
'text-halo-width': 1.2,
'text-halo-blur': 0.5,
},
} as unknown as LayerSpecification;
// z9+: 100, 500, 1000, 2000m 라벨
const bathyLabels: LayerSpecification = {
id: 'bathymetry-labels',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
filter: bathyMajorDepthFilter as unknown as unknown[],
minzoom: 9,
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const toInsert = [
bathyFill,
bathyBandBorders,
bathyBandBordersMajor,
bathyLinesMinor,
bathyBordersMajor,
bathyBorders,
bathyLinesCoarse,
bathyLinesMajor,
bathyLinesDetail,
bathyLabelsCoarse,
bathyLabels,
landformLabels,
].filter((l) => !existingIds.has(l.id));
@ -298,6 +382,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
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;
applyLandLayerLOD(json);
applyKoreanLabels(json);
injectOceanBathymetryLayers(json, key);
return json;