From b022e4bc36862ac8ca7017222dc1b70acdc58d74 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 15:15:31 +0900 Subject: [PATCH] =?UTF-8?q?perf(map):=20=EC=A4=8C=20=EA=B8=B0=EB=B0=98=20L?= =?UTF-8?q?OD=20+=20=EC=8B=AC=ED=95=B4=20=EB=93=B1=EC=8B=AC=EC=84=A0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../widgets/map3d/hooks/useBaseMapToggle.ts | 2 +- .../web/src/widgets/map3d/hooks/useMapInit.ts | 14 +- .../map3d/hooks/useMapStyleSettings.ts | 14 +- .../src/widgets/map3d/layers/bathymetry.ts | 209 ++++++++++++------ 4 files changed, 167 insertions(+), 72 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts index 9844603..b9f3287 100644 --- a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -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 diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index d14701e..17261db 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -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 } diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 00feb3f..1ea7049 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) { if (layer.type !== 'symbol') continue; const layout = (layer as { layout?: Record }).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); diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 4c4089b..da12e57 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -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 = { + '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; + 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