diff --git a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts new file mode 100644 index 0000000..6b5e0c6 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts @@ -0,0 +1,167 @@ +/** + * useNativeMapLayers — Mercator/Globe 공통 MapLibre 네이티브 레이어 관리 hook + * + * 반복되는 보일러플레이트를 자동화합니다: + * - projectionBusy / isStyleLoaded 가드 + * - GeoJSON source 생성/업데이트 + * - Layer 생성 (ensureLayer) + * - Visibility 토글 + * - Globe 레이어 순서 관리 (reorderGlobeFeatureLayers) + * - kickRepaint + * - Unmount 시 cleanupLayers + * + * 호버 하이라이트, 마우스 이벤트 등 레이어별 커스텀 로직은 + * 별도 useEffect에서 처리합니다. + */ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +/* ── Public types ──────────────────────────────────────────────────── */ + +export interface NativeSourceConfig { + id: string; + data: GeoJSON.GeoJSON | null; +} + +export interface NativeLayerSpec { + id: string; + type: 'line' | 'fill' | 'circle' | 'symbol'; + sourceId: string; + paint: Record; + layout?: Record; + filter?: unknown[]; + minzoom?: number; + maxzoom?: number; +} + +export interface NativeMapLayersConfig { + /** GeoJSON 데이터 소스 (다중 지원) */ + sources: NativeSourceConfig[]; + /** 레이어 스펙 배열 (생성 순서대로) */ + layers: NativeLayerSpec[]; + /** 전체 레이어 on/off */ + visible: boolean; + /** + * 이 레이어들을 삽입할 기준 레이어 ID. + * 배열이면 첫 번째로 존재하는 레이어를 사용합니다. + */ + beforeLayer?: string | string[]; + /** + * 레이어 (재)생성 후 호출되는 콜백. + * 호버 하이라이트 재적용 등에 사용합니다. + */ + onAfterSetup?: (map: maplibregl.Map) => void; +} + +/* ── Hook ──────────────────────────────────────────────────────────── */ + +/** + * @param mapRef - Map 인스턴스 ref + * @param projectionBusyRef - 프로젝션 전환 중 가드 ref + * @param reorderGlobeFeatureLayers - Globe 레이어 순서 재정렬 함수 + * @param config - 소스/레이어/visibility 설정 + * @param deps - 이 값이 변경되면 레이어를 다시 셋업합니다. + * (subcableGeo, overlays.subcables, projection, mapSyncEpoch 등) + */ +export function useNativeMapLayers( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + config: NativeMapLayersConfig, + deps: readonly unknown[], +) { + // 최신 config를 항상 읽기 위한 ref (deps에 config 객체를 넣지 않기 위함) + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }); + + /* ── 레이어 생성/데이터 업데이트 ─────────────────────────────────── */ + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const ensure = () => { + const cfg = configRef.current; + if (projectionBusyRef.current) return; + + // 1. Visibility 토글 + for (const spec of cfg.layers) { + setLayerVisibility(map, spec.id, cfg.visible); + } + + // 2. 데이터가 있는 source가 하나도 없으면 종료 + const hasData = cfg.sources.some((s) => s.data != null); + if (!hasData) return; + if (!map.isStyleLoaded()) return; + + try { + // 3. Source 생성/업데이트 + for (const src of cfg.sources) { + if (src.data) { + ensureGeoJsonSource(map, src.id, src.data); + } + } + + // 4. Before layer 해석 + let before: string | undefined; + if (cfg.beforeLayer) { + const candidates = Array.isArray(cfg.beforeLayer) ? cfg.beforeLayer : [cfg.beforeLayer]; + for (const candidate of candidates) { + if (map.getLayer(candidate)) { + before = candidate; + break; + } + } + } + + // 5. Layer 생성 + const vis = cfg.visible ? 'visible' : 'none'; + for (const spec of cfg.layers) { + const layerDef: Record = { + id: spec.id, + type: spec.type, + source: spec.sourceId, + paint: spec.paint, + layout: { ...spec.layout, visibility: vis }, + }; + if (spec.filter) layerDef.filter = spec.filter; + if (spec.minzoom != null) layerDef.minzoom = spec.minzoom; + if (spec.maxzoom != null) layerDef.maxzoom = spec.maxzoom; + + ensureLayer(map, layerDef as unknown as LayerSpecification, { before }); + } + + // 6. Post-setup callback + if (cfg.onAfterSetup) { + cfg.onAfterSetup(map); + } + } catch (e) { + console.warn('Native map layers setup failed:', e); + } finally { + reorderGlobeFeatureLayers(); + kickRepaint(map); + } + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + /* ── Unmount cleanup ─────────────────────────────────────────────── */ + useEffect(() => { + const mapInstance = mapRef.current; + return () => { + if (!mapInstance) return; + const cfg = configRef.current; + const layerIds = [...cfg.layers].reverse().map((l) => l.id); + const sourceIds = [...cfg.sources].reverse().map((s) => s.id); + cleanupLayers(mapInstance, layerIds, sourceIds); + }; + }, []); +} diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index b7211dd..59f9411 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; -import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import maplibregl from 'maplibre-gl'; import type { SubcableGeoJson } from '../../../entities/subcable/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { MapProjectionId } from '../types'; -import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; -import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { kickRepaint } from '../lib/mapCore'; +import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers'; /* ── Layer / Source IDs ─────────────────────────────────────────────── */ const SRC_ID = 'subcables-src'; @@ -17,9 +17,6 @@ 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]; - /* ── 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]; @@ -28,6 +25,87 @@ const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3. 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]; +/* ── Layer specifications ────────────────────────────────────────── */ +const LAYER_SPECS: NativeLayerSpec[] = [ + { + id: HITAREA_ID, + type: 'line', + sourceId: SRC_ID, + paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: CASING_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0.55)', + 'line-width': CASING_WIDTH_DEFAULT, + 'line-opacity': CASING_OPACITY_DEFAULT, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: LINE_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': LINE_OPACITY_DEFAULT, + 'line-width': LINE_WIDTH_DEFAULT, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: GLOW_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': 0, + '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: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: POINTS_ID, + type: 'circle', + sourceId: POINTS_SRC_ID, + paint: { + 'circle-radius': POINTS_RADIUS_DEFAULT, + 'circle-color': ['get', 'color'], + 'circle-opacity': POINTS_OPACITY_DEFAULT, + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-stroke-width': 0.5, + }, + minzoom: 3, + }, + { + id: LABEL_ID, + type: 'symbol', + sourceId: SRC_ID, + paint: { + '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], + }, + layout: { + 'symbol-placement': 'line', + 'text-field': ['get', 'name'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-allow-overlap': false, + 'text-padding': 8, + 'text-rotation-alignment': 'map', + }, + minzoom: 4, + }, +]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -46,13 +124,14 @@ export function useSubcablesLayer( const onHoverRef = useRef(onHoverCable); const onClickRef = useRef(onClickCable); - onHoverRef.current = onHoverCable; - onClickRef.current = onClickCable; - const hoveredCableIdRef = useRef(hoveredCableId); - hoveredCableIdRef.current = hoveredCableId; + useEffect(() => { + onHoverRef.current = onHoverCable; + onClickRef.current = onClickCable; + hoveredCableIdRef.current = hoveredCableId; + }); - /* ── Derived point features (cable midpoints for circle markers) ── */ + /* ── Derived point features ──────────────────────────────────────── */ const pointsGeoJson = useMemo(() => { if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; const features: GeoJSON.Feature[] = []; @@ -69,174 +148,27 @@ export function useSubcablesLayer( }, [subcableGeo]); /* ================================================================ - * Effect 1: Layer creation & data update - * - Does NOT depend on hoveredCableId (prevents flicker) - * - Creates sources, layers, sets visibility + * Effect 1: Layer creation & data update (via useNativeMapLayers) * ================================================================ */ - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const ensure = () => { - if (projectionBusyRef.current) return; - - 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' - : map.getLayer('deck-globe') - ? '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': CASING_WIDTH_DEFAULT, - 'line-opacity': CASING_OPACITY_DEFAULT, - }, - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 3) Main cable line — vivid color */ - ensureLayer( - map, - { - id: LINE_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': ['get', 'color'], - 'line-opacity': LINE_OPACITY_DEFAULT, - 'line-width': LINE_WIDTH_DEFAULT, - }, - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 4) Glow — visible only on hover */ - ensureLayer( - map, - { - id: GLOW_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': ['get', 'color'], - 'line-opacity': 0, - '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: 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': POINTS_RADIUS_DEFAULT, - 'circle-color': ['get', 'color'], - 'circle-opacity': POINTS_OPACITY_DEFAULT, - '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, - { - id: LABEL_ID, - type: 'symbol', - source: SRC_ID, - layout: { - visibility: vis, - 'symbol-placement': 'line', - 'text-field': ['get', 'name'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], - 'text-allow-overlap': false, - 'text-padding': 8, - 'text-rotation-alignment': 'map', - }, - paint: { - '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, - ); - - // Re-apply current hover state after layer (re-)creation - applyHoverHighlight(map, hoveredCableIdRef.current); - } catch (e) { - console.warn('Subcables layer setup failed:', e); - } finally { - reorderGlobeFeatureLayers(); - kickRepaint(map); - } - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - // hoveredCableId intentionally excluded — handled by Effect 2 - }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers]); + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: [ + { id: SRC_ID, data: subcableGeo }, + { id: POINTS_SRC_ID, data: pointsGeoJson }, + ], + layers: LAYER_SPECS, + visible: overlays.subcables, + beforeLayer: ['zones-fill', 'deck-globe'], + onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current), + }, + [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers], + ); /* ================================================================ * Effect 2: Hover highlight (paint-only, no layer creation) - * - Lightweight, no flicker * ================================================================ */ useEffect(() => { const map = mapRef.current; @@ -250,7 +182,6 @@ export function useSubcablesLayer( /* ================================================================ * Effect 3: Mouse events (bind to hit-area for easy hovering) - * - Retries binding until layer exists * ================================================================ */ useEffect(() => { const map = mapRef.current; @@ -284,7 +215,6 @@ export function useSubcablesLayer( 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; } @@ -314,15 +244,6 @@ export function useSubcablesLayer( } }; }, [overlays.subcables, mapSyncEpoch]); - - /* ── Cleanup on unmount ───────────────────────────────────────────── */ - useEffect(() => { - const mapInstance = mapRef.current; - return () => { - if (!mapInstance) return; - cleanupLayers(mapInstance, ALL_LAYER_IDS, ALL_SOURCE_IDS); - }; - }, []); } /* ── Hover highlight helper (paint-only mutations) ────────────────── */