From 7eff97afd4e5e28c3c6872a88fe399e50489bcd7 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 02:28:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(map):=20=ED=95=B4=EC=A0=80=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=8B=9C=EC=9D=B8=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapLibre 중첩 interpolate 표현식 에러 수정 - 6레이어 구조: hitarea, casing, line, glow, points, label - 호버 시 flat value 사용 (case 내 interpolate 제거) - Globe/Mercator 양쪽 프로젝션 레이어 순서 지원 - 진한 색상, 굵은 라인, 포인트 마커로 시인성 향상 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/Map3D.tsx | 2 +- .../map3d/hooks/useProjectionToggle.ts | 6 + .../widgets/map3d/hooks/useSubcablesLayer.ts | 222 +++++++++++++----- .../web/src/widgets/map3d/lib/layerHelpers.ts | 6 +- 4 files changed, 180 insertions(+), 56 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index b00753c..19e919a 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -505,7 +505,7 @@ export function Map3D({ }, ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + // eslint-disable-next-line @typescript-eslint/no-unused-vars const noopCable = useCallback((_: string | null) => {}, []); useSubcablesLayer( diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index ec1e0ce..2b92733 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -91,6 +91,12 @@ export function useProjectionToggle( if (!map.isStyleLoaded()) return; const ordering = [ + 'subcables-hitarea', + 'subcables-casing', + 'subcables-line', + 'subcables-glow', + 'subcables-points', + 'subcables-label', 'zones-fill', 'zones-line', 'zones-label', diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 38f7ff3..9e4c87b 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, type MutableRefObject } from 'react'; +import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; import maplibregl, { type LayerSpecification } from 'maplibre-gl'; import type { SubcableGeoJson } from '../../../entities/subcable/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; @@ -6,11 +6,20 @@ import type { MapProjectionId } from '../types'; import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +/* ── Layer / Source IDs ─────────────────────────────────────────────── */ const SRC_ID = 'subcables-src'; +const POINTS_SRC_ID = 'subcables-pts-src'; + +const HITAREA_ID = 'subcables-hitarea'; +const CASING_ID = 'subcables-casing'; const LINE_ID = 'subcables-line'; -const LINE_HOVER_ID = 'subcables-line-hover'; +const GLOW_ID = 'subcables-glow'; +const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; +const ALL_LAYER_IDS = [LABEL_ID, POINTS_ID, GLOW_ID, LINE_ID, CASING_ID, HITAREA_ID]; +const ALL_SOURCE_IDS = [POINTS_SRC_ID, SRC_ID]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -32,6 +41,23 @@ export function useSubcablesLayer( onHoverRef.current = onHoverCable; onClickRef.current = onClickCable; + /* ── Derived point features (cable midpoints for circle markers) ── */ + const pointsGeoJson = useMemo(() => { + if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; + const features: GeoJSON.Feature[] = []; + for (const f of subcableGeo.features) { + const coords = f.properties.coordinates; + if (!coords || coords.length < 2) continue; + features.push({ + type: 'Feature', + properties: { id: f.properties.id, color: f.properties.color }, + geometry: { type: 'Point', coordinates: coords }, + }); + } + return { type: 'FeatureCollection', features }; + }, [subcableGeo]); + + /* ── Main layer setup effect ──────────────────────────────────────── */ useEffect(() => { const map = mapRef.current; if (!map) return; @@ -39,16 +65,17 @@ export function useSubcablesLayer( const ensure = () => { if (projectionBusyRef.current) return; - const visibility = overlays.subcables ? 'visible' : 'none'; - setLayerVisibility(map, LINE_ID, overlays.subcables); - setLayerVisibility(map, LINE_HOVER_ID, overlays.subcables); - setLayerVisibility(map, LABEL_ID, overlays.subcables); + const visible = overlays.subcables; + for (const id of ALL_LAYER_IDS) { + setLayerVisibility(map, id, visible); + } if (!subcableGeo) return; if (!map.isStyleLoaded()) return; try { ensureGeoJsonSource(map, SRC_ID, subcableGeo); + ensureGeoJsonSource(map, POINTS_SRC_ID, pointsGeoJson); const before = map.getLayer('zones-fill') ? 'zones-fill' @@ -56,6 +83,43 @@ export function useSubcablesLayer( ? 'deck-globe' : undefined; + const vis = visible ? 'visible' : 'none'; + + /* 1) Hit-area — invisible wide line for easy hover detection */ + ensureLayer( + map, + { + id: HITAREA_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0)', + 'line-width': 14, + 'line-opacity': 0, + }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + /* 2) Dark casing behind cable for contrast */ + ensureLayer( + map, + { + id: CASING_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0.55)', + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65], + }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + /* 3) Main cable line — vivid color */ ensureLayer( map, { @@ -64,31 +128,53 @@ export function useSubcablesLayer( source: SRC_ID, paint: { 'line-color': ['get', 'color'], - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7], - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0], }, - layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, { before }, ); + /* 4) Glow — visible only on hover */ ensureLayer( map, { - id: LINE_HOVER_ID, + id: GLOW_ID, type: 'line', source: SRC_ID, paint: { 'line-color': ['get', 'color'], 'line-opacity': 0, - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 8], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], }, filter: ['==', ['get', 'id'], ''], - layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, { before }, ); + /* 5) Point markers at cable representative coordinates */ + ensureLayer( + map, + { + id: POINTS_ID, + type: 'circle', + source: POINTS_SRC_ID, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4], + 'circle-color': ['get', 'color'], + 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85], + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-stroke-width': 0.5, + }, + layout: { visibility: vis }, + minzoom: 3, + } as unknown as LayerSpecification, + ); + + /* 6) Cable name label along line */ ensureLayer( map, { @@ -96,7 +182,7 @@ export function useSubcablesLayer( type: 'symbol', source: SRC_ID, layout: { - visibility, + visibility: vis, 'symbol-placement': 'line', 'text-field': ['get', 'name'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], @@ -106,52 +192,75 @@ export function useSubcablesLayer( 'text-rotation-alignment': 'map', }, paint: { - 'text-color': 'rgba(210,225,240,0.78)', - 'text-halo-color': 'rgba(2,6,23,0.85)', - 'text-halo-width': 1.0, - 'text-halo-blur': 0.6, - 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.85], + 'text-color': 'rgba(220,232,245,0.82)', + 'text-halo-color': 'rgba(2,6,23,0.9)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.5, + 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88], }, minzoom: 4, } as unknown as LayerSpecification, ); - // Update hover highlight + /* ── Hover highlight (flat values — no nested interpolate) ── */ if (hoveredCableId) { + const matchExpr = ['==', ['get', 'id'], hoveredCableId]; + + // Main line: hovered=bright+thick, rest=dimmed+thin if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', [ - 'case', - ['==', ['get', 'id'], hoveredCableId], - 0.95, - ['interpolate', ['linear'], ['zoom'], 2, 0.25, 6, 0.35, 10, 0.45], - ] as never); - map.setPaintProperty(LINE_ID, 'line-width', [ - 'case', - ['==', ['get', 'id'], hoveredCableId], - ['interpolate', ['linear'], ['zoom'], 2, 2.0, 6, 2.8, 10, 3.5], - ['interpolate', ['linear'], ['zoom'], 2, 0.6, 6, 0.9, 10, 1.4], - ] as never); + map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); + map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); } - if (map.getLayer(LINE_HOVER_ID)) { - map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], hoveredCableId] as never); - map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0.25); + // Casing: dim non-hovered + if (map.getLayer(CASING_ID)) { + map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); + map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); + } + // Glow: show only on hovered cable + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, matchExpr as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + } + // Points: dim non-hovered + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); + map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); } } else { + // Restore zoom-based interpolation defaults if (map.getLayer(LINE_ID)) { map.setPaintProperty( - LINE_ID, - 'line-opacity', - ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7] as never, + LINE_ID, 'line-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92] as never, ); map.setPaintProperty( - LINE_ID, - 'line-width', - ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8] as never, + LINE_ID, 'line-width', + ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0] as never, ); } - if (map.getLayer(LINE_HOVER_ID)) { - map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], ''] as never); - map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0); + if (map.getLayer(CASING_ID)) { + map.setPaintProperty( + CASING_ID, 'line-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65] as never, + ); + map.setPaintProperty( + CASING_ID, 'line-width', + ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5] as never, + ); + } + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0); + } + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty( + POINTS_ID, 'circle-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85] as never, + ); + map.setPaintProperty( + POINTS_ID, 'circle-radius', + ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4] as never, + ); } } } catch (e) { @@ -166,15 +275,15 @@ export function useSubcablesLayer( return () => { stop(); }; - }, [subcableGeo, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); + }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); - // Mouse events + /* ── Mouse events (bind to hit-area layer for easy hovering) ───── */ useEffect(() => { const map = mapRef.current; if (!map) return; if (!overlays.subcables) return; - const onMouseEnter = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const cableId = e.features?.[0]?.properties?.id; if (typeof cableId === 'string' && cableId) { map.getCanvas().style.cursor = 'pointer'; @@ -195,13 +304,15 @@ export function useSubcablesLayer( }; const addEvents = () => { - if (!map.getLayer(LINE_ID)) return; - map.on('mouseenter', LINE_ID, onMouseEnter); - map.on('mouseleave', LINE_ID, onMouseLeave); - map.on('click', LINE_ID, onClick); + // Bind to hit-area for wider hover target, fallback to main line + const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : LINE_ID; + if (!map.getLayer(targetLayer)) return; + map.on('mousemove', targetLayer, onMouseMove); + map.on('mouseleave', targetLayer, onMouseLeave); + map.on('click', targetLayer, onClick); }; - if (map.isStyleLoaded() && map.getLayer(LINE_ID)) { + if (map.isStyleLoaded() && (map.getLayer(HITAREA_ID) || map.getLayer(LINE_ID))) { addEvents(); } else { map.once('idle', addEvents); @@ -209,7 +320,10 @@ export function useSubcablesLayer( return () => { try { - map.off('mouseenter', LINE_ID, onMouseEnter); + map.off('mousemove', HITAREA_ID, onMouseMove); + map.off('mouseleave', HITAREA_ID, onMouseLeave); + map.off('click', HITAREA_ID, onClick); + map.off('mousemove', LINE_ID, onMouseMove); map.off('mouseleave', LINE_ID, onMouseLeave); map.off('click', LINE_ID, onClick); } catch { @@ -218,12 +332,12 @@ export function useSubcablesLayer( }; }, [overlays.subcables, mapSyncEpoch]); - // Cleanup on unmount + /* ── Cleanup on unmount ───────────────────────────────────────────── */ useEffect(() => { const mapInstance = mapRef.current; return () => { if (!mapInstance) return; - cleanupLayers(mapInstance, [LABEL_ID, LINE_HOVER_ID, LINE_ID], [SRC_ID]); + cleanupLayers(mapInstance, ALL_LAYER_IDS, ALL_SOURCE_IDS); }; }, []); } diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 0c335e5..bfe12ca 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -37,8 +37,11 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', + 'subcables-hitarea', + 'subcables-casing', 'subcables-line', - 'subcables-line-hover', + 'subcables-glow', + 'subcables-points', 'subcables-label', 'deck-globe', ]; @@ -52,6 +55,7 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'fleet-circles-ml-fill-src', 'pair-range-ml-src', 'subcables-src', + 'subcables-pts-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) {