gc-wing/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
htlee f5ef24c02f perf(map): 해저케이블 렌더링 최적화
- GeoJSON source tolerance:1, buffer:64 (저줌 vertex 단순화)
- hitarea/casing/glow 레이어 minzoom:3 (저줌 렌더 제외)
- ensureGeoJsonSource에 source options 파라미터 추가
- NativeSourceConfig에 options 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:28:44 +09:00

292 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
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 { kickRepaint } from '../lib/mapCore';
import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers';
/* ── 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 GLOW_ID = 'subcables-glow';
const POINTS_ID = 'subcables-points';
const LABEL_ID = 'subcables-label';
/* ── 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];
/* ── 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' },
minzoom: 3,
},
{
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' },
minzoom: 3,
},
{
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' },
minzoom: 3,
},
{
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<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
subcableGeo: SubcableGeoJson | null;
overlays: MapToggleState;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredCableId: string | null;
onHoverCable: (cableId: string | null) => void;
onClickCable: (cableId: string | null) => void;
},
) {
const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts;
const onHoverRef = useRef(onHoverCable);
const onClickRef = useRef(onClickCable);
const hoveredCableIdRef = useRef(hoveredCableId);
useEffect(() => {
onHoverRef.current = onHoverCable;
onClickRef.current = onClickCable;
hoveredCableIdRef.current = hoveredCableId;
});
/* ── Derived point features ──────────────────────────────────────── */
const pointsGeoJson = useMemo<GeoJSON.FeatureCollection>(() => {
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
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]);
/* ================================================================
* Effect 1: Layer creation & data update (via useNativeMapLayers)
* ================================================================ */
useNativeMapLayers(
mapRef,
projectionBusyRef,
reorderGlobeFeatureLayers,
{
sources: [
{ id: SRC_ID, data: subcableGeo, options: { tolerance: 1, buffer: 64 } },
{ 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)
* ================================================================ */
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)
* ================================================================ */
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!overlays.subcables) return;
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
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';
onHoverRef.current(cableId);
}
};
const onMouseLeave = () => {
map.getCanvas().style.cursor = '';
onHoverRef.current(null);
};
const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const cableId = e.features?.[0]?.properties?.id;
if (typeof cableId === 'string' && cableId) {
onClickRef.current(cableId);
}
};
const bindEvents = () => {
if (cancelled) return;
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
if (!targetLayer) {
retryTimer = setTimeout(bindEvents, 200);
return;
}
map.on('mousemove', targetLayer, onMouseMove);
map.on('mouseleave', targetLayer, onMouseLeave);
map.on('click', targetLayer, onClick);
};
if (map.isStyleLoaded()) {
bindEvents();
} else {
map.once('idle', bindEvents);
}
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
try {
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 {
// ignore
}
};
}, [overlays.subcables, mapSyncEpoch]);
}
/* ── 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);
}
}
}