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'; 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 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]; 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, 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); 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: [] }; 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]); /* ================================================================ * 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; 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]); /* ================================================================ * 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) { 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) { // 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()) { 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]); /* ── 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) ────────────────── */ 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); } } }