From fb1334ce45dd9dce3103afbd3a8e3919c20719fc Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 02:36:20 +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=ED=98=B8=EB=B2=84/=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=EC=85=98=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useEffect 3개 분리 (레이어생성/호버/이벤트) - hoveredCableId를 레이어 생성 deps에서 분리하여 깜박임 제거 - 이벤트 바인딩에 retry 로직 추가 (프로젝션 전환 후) - paint 기본값을 상수로 추출하여 일관성 보장 Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useSubcablesLayer.ts | 178 ++++++++++-------- 1 file changed, 101 insertions(+), 77 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 9e4c87b..b7211dd 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -20,6 +20,14 @@ 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]; +/* ── Paint defaults (used for layer creation + hover reset) ──────── */ +const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; +const LINE_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0]; +const CASING_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65]; +const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5]; +const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85]; +const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -41,6 +49,9 @@ export function useSubcablesLayer( onHoverRef.current = onHoverCable; onClickRef.current = onClickCable; + const hoveredCableIdRef = useRef(hoveredCableId); + hoveredCableIdRef.current = hoveredCableId; + /* ── Derived point features (cable midpoints for circle markers) ── */ const pointsGeoJson = useMemo(() => { if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; @@ -57,7 +68,11 @@ export function useSubcablesLayer( return { type: 'FeatureCollection', features }; }, [subcableGeo]); - /* ── Main layer setup effect ──────────────────────────────────────── */ + /* ================================================================ + * Effect 1: Layer creation & data update + * - Does NOT depend on hoveredCableId (prevents flicker) + * - Creates sources, layers, sets visibility + * ================================================================ */ useEffect(() => { const map = mapRef.current; if (!map) return; @@ -111,8 +126,8 @@ export function useSubcablesLayer( 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], + 'line-width': CASING_WIDTH_DEFAULT, + 'line-opacity': CASING_OPACITY_DEFAULT, }, layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, @@ -128,8 +143,8 @@ export function useSubcablesLayer( source: SRC_ID, paint: { 'line-color': ['get', 'color'], - '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], + 'line-opacity': LINE_OPACITY_DEFAULT, + 'line-width': LINE_WIDTH_DEFAULT, }, layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, @@ -163,9 +178,9 @@ export function useSubcablesLayer( type: 'circle', source: POINTS_SRC_ID, paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4], + 'circle-radius': POINTS_RADIUS_DEFAULT, 'circle-color': ['get', 'color'], - 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85], + 'circle-opacity': POINTS_OPACITY_DEFAULT, 'circle-stroke-color': 'rgba(0,0,0,0.5)', 'circle-stroke-width': 0.5, }, @@ -202,67 +217,8 @@ export function useSubcablesLayer( } as unknown as LayerSpecification, ); - /* ── 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', matchExpr, 1.0, 0.2] as never); - map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); - } - // 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.7, 6, 0.82, 10, 0.92] as never, - ); - map.setPaintProperty( - LINE_ID, 'line-width', - ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0] as never, - ); - } - 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, - ); - } - } + // Re-apply current hover state after layer (re-)creation + applyHoverHighlight(map, hoveredCableIdRef.current); } catch (e) { console.warn('Subcables layer setup failed:', e); } finally { @@ -275,14 +231,35 @@ export function useSubcablesLayer( return () => { stop(); }; - }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); + // hoveredCableId intentionally excluded — handled by Effect 2 + }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers]); - /* ── Mouse events (bind to hit-area layer for easy hovering) ───── */ + /* ================================================================ + * Effect 2: Hover highlight (paint-only, no layer creation) + * - Lightweight, no flicker + * ================================================================ */ + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (projectionBusyRef.current) return; + if (!map.getLayer(LINE_ID)) return; + + applyHoverHighlight(map, hoveredCableId); + kickRepaint(map); + }, [hoveredCableId]); + + /* ================================================================ + * Effect 3: Mouse events (bind to hit-area for easy hovering) + * - Retries binding until layer exists + * ================================================================ */ useEffect(() => { const map = mapRef.current; if (!map) return; if (!overlays.subcables) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const cableId = e.features?.[0]?.properties?.id; if (typeof cableId === 'string' && cableId) { @@ -303,22 +280,28 @@ export function useSubcablesLayer( } }; - const addEvents = () => { - // 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; + const bindEvents = () => { + if (cancelled) return; + const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null; + if (!targetLayer) { + // Layer not yet created — retry after short delay + retryTimer = setTimeout(bindEvents, 200); + return; + } map.on('mousemove', targetLayer, onMouseMove); map.on('mouseleave', targetLayer, onMouseLeave); map.on('click', targetLayer, onClick); }; - if (map.isStyleLoaded() && (map.getLayer(HITAREA_ID) || map.getLayer(LINE_ID))) { - addEvents(); + if (map.isStyleLoaded()) { + bindEvents(); } else { - map.once('idle', addEvents); + map.once('idle', bindEvents); } return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); try { map.off('mousemove', HITAREA_ID, onMouseMove); map.off('mouseleave', HITAREA_ID, onMouseLeave); @@ -341,3 +324,44 @@ export function useSubcablesLayer( }; }, []); } + +/* ── Hover highlight helper (paint-only mutations) ────────────────── */ +function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { + if (hoveredId) { + const matchExpr = ['==', ['get', 'id'], hoveredId]; + + if (map.getLayer(LINE_ID)) { + 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(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); + } + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, matchExpr as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + } + 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 { + if (map.getLayer(LINE_ID)) { + map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never); + map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never); + } + if (map.getLayer(CASING_ID)) { + map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never); + map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT 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', POINTS_OPACITY_DEFAULT as never); + map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); + } + } +}