feat(map): 통합 레이어 모듈 구현
- useNativeMapLayers 범용 hook 생성 - source/layer 생성, visibility, cleanup 자동화 - projectionBusy/isStyleLoaded 가드 내장 - Globe 레이어 순서 관리 내장 - beforeLayer 후보 배열 지원 - useSubcablesLayer를 useNativeMapLayers로 전환 - React Compiler ref 접근 규칙 준수 (useEffect 내 할당) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
fb1334ce45
커밋
a16ccc9a01
167
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
167
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
layout?: Record<string, unknown>;
|
||||||
|
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<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
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<string, unknown> = {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
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 { SubcableGeoJson } from '../../../entities/subcable/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { MapProjectionId } from '../types';
|
import type { MapProjectionId } from '../types';
|
||||||
import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers';
|
import { kickRepaint } from '../lib/mapCore';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers';
|
||||||
|
|
||||||
/* ── Layer / Source IDs ─────────────────────────────────────────────── */
|
/* ── Layer / Source IDs ─────────────────────────────────────────────── */
|
||||||
const SRC_ID = 'subcables-src';
|
const SRC_ID = 'subcables-src';
|
||||||
@ -17,9 +17,6 @@ const GLOW_ID = 'subcables-glow';
|
|||||||
const POINTS_ID = 'subcables-points';
|
const POINTS_ID = 'subcables-points';
|
||||||
const LABEL_ID = 'subcables-label';
|
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) ──────── */
|
/* ── 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_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 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_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];
|
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(
|
export function useSubcablesLayer(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
@ -46,13 +124,14 @@ export function useSubcablesLayer(
|
|||||||
|
|
||||||
const onHoverRef = useRef(onHoverCable);
|
const onHoverRef = useRef(onHoverCable);
|
||||||
const onClickRef = useRef(onClickCable);
|
const onClickRef = useRef(onClickCable);
|
||||||
onHoverRef.current = onHoverCable;
|
|
||||||
onClickRef.current = onClickCable;
|
|
||||||
|
|
||||||
const hoveredCableIdRef = useRef(hoveredCableId);
|
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<GeoJSON.FeatureCollection>(() => {
|
const pointsGeoJson = useMemo<GeoJSON.FeatureCollection>(() => {
|
||||||
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
|
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||||
@ -69,174 +148,27 @@ export function useSubcablesLayer(
|
|||||||
}, [subcableGeo]);
|
}, [subcableGeo]);
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Effect 1: Layer creation & data update
|
* Effect 1: Layer creation & data update (via useNativeMapLayers)
|
||||||
* - Does NOT depend on hoveredCableId (prevents flicker)
|
|
||||||
* - Creates sources, layers, sets visibility
|
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
useEffect(() => {
|
useNativeMapLayers(
|
||||||
const map = mapRef.current;
|
mapRef,
|
||||||
if (!map) return;
|
projectionBusyRef,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
const ensure = () => {
|
{
|
||||||
if (projectionBusyRef.current) return;
|
sources: [
|
||||||
|
{ id: SRC_ID, data: subcableGeo },
|
||||||
const visible = overlays.subcables;
|
{ id: POINTS_SRC_ID, data: pointsGeoJson },
|
||||||
for (const id of ALL_LAYER_IDS) {
|
],
|
||||||
setLayerVisibility(map, id, visible);
|
layers: LAYER_SPECS,
|
||||||
}
|
visible: overlays.subcables,
|
||||||
|
beforeLayer: ['zones-fill', 'deck-globe'],
|
||||||
if (!subcableGeo) return;
|
onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current),
|
||||||
if (!map.isStyleLoaded()) return;
|
},
|
||||||
|
[subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers],
|
||||||
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]);
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Effect 2: Hover highlight (paint-only, no layer creation)
|
* Effect 2: Hover highlight (paint-only, no layer creation)
|
||||||
* - Lightweight, no flicker
|
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -250,7 +182,6 @@ export function useSubcablesLayer(
|
|||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Effect 3: Mouse events (bind to hit-area for easy hovering)
|
* Effect 3: Mouse events (bind to hit-area for easy hovering)
|
||||||
* - Retries binding until layer exists
|
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -284,7 +215,6 @@ export function useSubcablesLayer(
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
|
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
|
||||||
if (!targetLayer) {
|
if (!targetLayer) {
|
||||||
// Layer not yet created — retry after short delay
|
|
||||||
retryTimer = setTimeout(bindEvents, 200);
|
retryTimer = setTimeout(bindEvents, 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -314,15 +244,6 @@ export function useSubcablesLayer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [overlays.subcables, mapSyncEpoch]);
|
}, [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) ────────────────── */
|
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user