From 95d9ea8aef4193138fb475de9a83e66c9cd00b9e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:34:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(map):=20=EB=9D=BC=EB=B2=A8=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=90=20+=20easing=20=EA=B2=BD=EA=B3=A0=20+=20vert?= =?UTF-8?q?ex=20=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty 호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한 text-allow-overlap:false 라벨 사라짐 현상 해결 - useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거 (data-driven 표현식은 addLayer 시 1회 설정으로 충분) - _render 래퍼에서 globe scrollZoom easing 경고 억제 - fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인) Co-Authored-By: Claude Opus 4.6 --- .../map3d/hooks/useGlobeInteraction.ts | 8 +- .../widgets/map3d/hooks/useGlobeOverlays.ts | 123 ++---------------- .../src/widgets/map3d/hooks/useGlobeShips.ts | 119 +++-------------- .../web/src/widgets/map3d/hooks/useMapInit.ts | 13 +- .../map3d/hooks/useProjectionToggle.ts | 1 - .../web/src/widgets/map3d/lib/layerHelpers.ts | 18 ++- 6 files changed, 64 insertions(+), 218 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts index 96892ae..90e571c 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -118,7 +118,7 @@ export function useGlobeInteraction( }); } - if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') { + if (layerId === 'fleet-circles-ml') { return getFleetCircleTooltipHtml({ ownerKey: String(props.ownerKey ?? ''), ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), @@ -186,7 +186,7 @@ export function useGlobeInteraction( candidateLayerIds = [ 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'pair-lines-ml', 'fc-lines-ml', - 'fleet-circles-ml', 'fleet-circles-ml-fill', + 'fleet-circles-ml', 'pair-range-ml', 'zones-fill', 'zones-line', 'zones-label', ].filter((id) => map.getLayer(id)); @@ -213,7 +213,7 @@ export function useGlobeInteraction( const priority = [ 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', + 'fleet-circles-ml', 'zones-fill', 'zones-line', 'zones-label', ]; @@ -232,7 +232,7 @@ export function useGlobeInteraction( const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; const isFcLayer = layerId === 'fc-lines-ml'; - const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill'; + const isFleetLayer = layerId === 'fleet-circles-ml'; const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; if (isShipLayer) { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index 2803246..db3768b 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -11,7 +11,6 @@ import { PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, - FLEET_FILL_ML, FLEET_FILL_ML_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL, } from '../constants'; import { makeUniqueSorted } from '../lib/setUtils'; @@ -28,6 +27,7 @@ import { } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; import { dashifyLine } from '../lib/dashifyLine'; export function useGlobeOverlays( @@ -60,11 +60,7 @@ export function useGlobeOverlays( const layerId = 'pair-lines-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; const ensure = () => { @@ -132,11 +128,7 @@ export function useGlobeOverlays( console.warn('Pair lines layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); @@ -159,11 +151,7 @@ export function useGlobeOverlays( const layerId = 'fc-lines-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; const ensure = () => { @@ -235,11 +223,7 @@ export function useGlobeOverlays( console.warn('FC lines layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); @@ -259,21 +243,13 @@ export function useGlobeOverlays( if (!map) return; const srcId = 'fleet-circles-ml-src'; - const fillSrcId = 'fleet-circles-ml-fill-src'; const layerId = 'fleet-circles-ml'; - const fillLayerId = 'fleet-circles-ml-fill'; + + // fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인 + // 라인만으로 fleet circle 시각화 충분 const remove = () => { - try { - if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); - } catch { - // ignore - } - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; const ensure = () => { @@ -304,29 +280,6 @@ export function useGlobeOverlays( }), }; - // fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로 - // 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정 - const MAX_FILL_RADIUS_M = 500 * 1852; - const fcFill: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, Math.min(c.radiusNm * 1852, MAX_FILL_RADIUS_M), 24); - return { - type: 'Feature', - id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, - geometry: { type: 'Polygon', coordinates: [ring] }, - properties: { - type: 'fleet-fill', - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - highlighted: 0, - }, - }; - }), - }; - try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fcLine); @@ -336,41 +289,6 @@ export function useGlobeOverlays( return; } - try { - const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; - if (existingFill) existingFill.setData(fcFill); - else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Fleet circles source setup failed:', e); - return; - } - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: 'fill', - source: fillSrcId, - layout: { visibility: 'visible' }, - paint: { - 'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never, - 'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles fill layer add failed:', e); - } - } else { - try { - map.setLayoutProperty(fillLayerId, 'visibility', 'visible'); - } catch { - // ignore - } - } - if (!map.getLayer(layerId)) { try { map.addLayer( @@ -391,11 +309,7 @@ export function useGlobeOverlays( console.warn('Fleet circles layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); @@ -418,11 +332,7 @@ export function useGlobeOverlays( const layerId = 'pair-range-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; const ensure = () => { @@ -506,11 +416,7 @@ export function useGlobeOverlays( console.warn('Pair range layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } kickRepaint(map); @@ -596,10 +502,7 @@ export function useGlobeOverlays( } try { - if (map.getLayer('fleet-circles-ml-fill')) { - map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never); - map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never); - } + // fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인) if (map.getLayer('fleet-circles-ml')) { map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 0b6a08e..5d55bdb 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -25,6 +25,7 @@ import { ensureFallbackShipImage, } from '../lib/globeShipIcon'; import { clampNumber } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; export function useGlobeShips( mapRef: MutableRefObject, @@ -273,11 +274,10 @@ export function useGlobeShips( const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); - } catch { /* ignore */ } + guardedSetVisibility(map, id, 'none'); } }; @@ -296,15 +296,19 @@ export function useGlobeShips( } // 빠른 visibility 토글 — projectionBusy 중에도 실행 - // 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선 - const visibility = projection === 'globe' ? 'visible' : 'none'; - const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; + // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출 + // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 + const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; + const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; if (map.getLayer(symbolId)) { - for (const id of [haloId, outlineId, symbolId]) { - try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ } + const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility; + if (changed) { + for (const id of [haloId, outlineId, symbolId]) { + guardedSetVisibility(map, id, visibility); + } + if (projection === 'globe') kickRepaint(map); } - try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); } catch { /* ignore */ } - if (projection === 'globe') kickRepaint(map); + guardedSetVisibility(map, labelId, labelVisibility); } // 데이터 업데이트는 projectionBusy 중에는 차단 @@ -374,35 +378,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship halo layer add failed:', e); } - } else { - try { - map.setLayoutProperty(haloId, 'visibility', visibility); - map.setLayoutProperty(haloId, 'circle-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, - ['==', ['get', 'permitted'], 1], 110, - ['==', ['get', 'selected'], 1], 60, - ['==', ['get', 'highlighted'], 1], 55, - 20, - ] as never); - map.setPaintProperty(haloId, 'circle-color', [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)', - ['coalesce', ['get', 'shipColor'], '#64748b'], - ] as never); - map.setPaintProperty(haloId, 'circle-opacity', [ - 'case', - ['==', ['get', 'selected'], 1], 0.38, - ['==', ['get', 'highlighted'], 1], 0.34, - 0.16, - ] as never); - map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR); - } catch { - // ignore - } } + // halo: data-driven expressions are static — visibility handled by fast toggle above if (!map.getLayer(outlineId)) { try { @@ -448,36 +425,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship outline layer add failed:', e); } - } else { - try { - map.setLayoutProperty(outlineId, 'visibility', visibility); - map.setLayoutProperty(outlineId, 'circle-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, - ['==', ['get', 'permitted'], 1], 120, - ['==', ['get', 'selected'], 1], 70, - ['==', ['get', 'highlighted'], 1], 65, - 30, - ] as never); - map.setPaintProperty(outlineId, 'circle-stroke-color', [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, - ] as never); - map.setPaintProperty(outlineId, 'circle-stroke-width', [ - 'case', - ['==', ['get', 'selected'], 1], 3.4, - ['==', ['get', 'highlighted'], 1], 2.7, - ['==', ['get', 'permitted'], 1], 1.8, - 0.7, - ] as never); - } catch { - // ignore - } } + // outline: data-driven expressions are static — visibility handled by fast toggle if (!map.getLayer(symbolId)) { try { @@ -538,29 +487,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship symbol layer add failed:', e); } - } else { - try { - map.setLayoutProperty(symbolId, 'visibility', visibility); - map.setLayoutProperty(symbolId, 'symbol-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, - ['==', ['get', 'permitted'], 1], 130, - ['==', ['get', 'selected'], 1], 80, - ['==', ['get', 'highlighted'], 1], 75, - 45, - ] as never); - map.setPaintProperty(symbolId, 'icon-opacity', [ - 'case', - ['==', ['get', 'permitted'], 1], 1, - ['==', ['get', 'selected'], 1], 0.86, - ['==', ['get', 'highlighted'], 1], 0.82, - 0.66, - ] as never); - } catch { - // ignore - } } + // symbol: data-driven expressions are static — visibility handled by fast toggle const labelFilter = [ 'all', @@ -611,15 +539,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship label layer add failed:', e); } - } else { - try { - map.setLayoutProperty(labelId, 'visibility', labelVisibility); - map.setFilter(labelId, labelFilter as never); - map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); - } catch { - // ignore - } } + // label: filter/text-field are static — visibility handled by fast toggle // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); @@ -658,9 +579,7 @@ export function useGlobeShips( const hideHover = () => { for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); - } catch { /* ignore */ } + guardedSetVisibility(map, id, 'none'); } }; diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index a9a325e..d14701e 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -93,11 +93,19 @@ export function useMapInit( map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); - // MapLibre 내부 placement TypeError 방어 + // MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제 // symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제 + // globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제 { const origRender = (map as unknown as { _render: (arg?: number) => void })._render; + const origWarn = console.warn; (map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) { + // 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; + origWarn.apply(console, args as [unknown, ...unknown[]]); + }; try { origRender.call(this, arg); } catch (e) { @@ -105,6 +113,9 @@ export function useMapInit( return; } throw e; + } finally { + // eslint-disable-next-line no-console + console.warn = origWarn; } }; } diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index a4f0cd3..9bc33de 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -111,7 +111,6 @@ export function useProjectionToggle( 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', ]; diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index a49ae3a..f5277a2 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -28,7 +28,6 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) { const GLOBE_NATIVE_LAYER_IDS = [ 'pair-lines-ml', 'fc-lines-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', 'subcables-hitarea', @@ -44,7 +43,6 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'pair-lines-ml-src', 'fc-lines-ml-src', 'fleet-circles-ml-src', - 'fleet-circles-ml-fill-src', 'pair-range-ml-src', 'subcables-src', 'subcables-pts-src', @@ -96,6 +94,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible } } +/** + * setLayoutProperty('visibility') wrapper — 현재 값과 동일하면 호출 생략. + * MapLibre는 setLayoutProperty 호출 시 항상 style._changed = true를 설정하여 + * 모든 symbol layer의 placement를 재계산시킴. text-allow-overlap:false 라벨이 + * 충돌 검사에 의해 사라지는 문제를 방지하기 위해, 값이 실제로 바뀔 때만 호출. + */ +export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') { + if (!map.getLayer(layerId)) return; + try { + if (map.getLayoutProperty(layerId, 'visibility') === target) return; + map.setLayoutProperty(layerId, 'visibility', target); + } catch { + // ignore + } +} + export function cleanupLayers( map: maplibregl.Map, layerIds: string[],