2026-02-17 10:52:51 +09:00
|
|
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
2026-02-16 23:44:19 +09:00
|
|
|
import type maplibregl from 'maplibre-gl';
|
|
|
|
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
|
|
|
|
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
|
|
|
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
|
|
|
|
import type { MapProjectionId, PairRangeCircle } from '../types';
|
|
|
|
|
import {
|
|
|
|
|
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
|
|
|
|
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
|
|
|
|
} from '../constants';
|
|
|
|
|
import { makePairLinkFeatureId } from '../lib/featureIds';
|
|
|
|
|
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
|
|
|
|
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|
|
|
|
import { circleRingLngLat } from '../lib/geometry';
|
|
|
|
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// ── Overlay line width constants ──
|
|
|
|
|
const PAIR_LINE_W_NORMAL = 2.5;
|
|
|
|
|
const PAIR_LINE_W_WARN = 3.5;
|
|
|
|
|
const PAIR_LINE_W_HL = 4.5;
|
|
|
|
|
const PAIR_RANGE_W_NORMAL = 1.8;
|
|
|
|
|
const PAIR_RANGE_W_HL = 2.8;
|
|
|
|
|
|
|
|
|
|
// ── Breathing animation constants ──
|
|
|
|
|
const BREATHE_AMP = 2.0;
|
|
|
|
|
const BREATHE_PERIOD_MS = 1200;
|
|
|
|
|
|
2026-02-16 23:44:19 +09:00
|
|
|
/** Globe pair lines + pair range 오버레이 */
|
|
|
|
|
export function useGlobePairOverlay(
|
|
|
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
|
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
|
|
|
reorderGlobeFeatureLayers: () => void,
|
|
|
|
|
opts: {
|
|
|
|
|
overlays: MapToggleState;
|
|
|
|
|
pairLinks: PairLink[] | undefined;
|
|
|
|
|
projection: MapProjectionId;
|
|
|
|
|
mapSyncEpoch: number;
|
|
|
|
|
hoveredPairMmsiList: number[];
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
2026-02-17 10:52:51 +09:00
|
|
|
const breatheRafRef = useRef<number>(0);
|
2026-02-16 23:44:19 +09:00
|
|
|
|
|
|
|
|
// Pair lines
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = 'pair-lines-ml-src';
|
|
|
|
|
const layerId = 'pair-lines-ml';
|
|
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
guardedSetVisibility(map, layerId, 'none');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
|
|
|
|
if (projectionBusyRef.current) return;
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
|
|
|
|
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: (pairLinks || []).map((p) => ({
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
|
|
|
|
|
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
|
|
|
|
|
properties: {
|
|
|
|
|
type: 'pair',
|
|
|
|
|
aMmsi: p.aMmsi,
|
|
|
|
|
bMmsi: p.bMmsi,
|
|
|
|
|
distanceNm: p.distanceNm,
|
|
|
|
|
warn: p.warn,
|
|
|
|
|
},
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
|
|
|
|
if (existing) existing.setData(fc);
|
|
|
|
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Pair lines source setup failed:', e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(layerId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: layerId,
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: srcId,
|
|
|
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
|
|
|
|
paint: {
|
|
|
|
|
'line-color': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['get', 'highlighted'], 1],
|
|
|
|
|
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
|
|
|
|
['boolean', ['get', 'warn'], false],
|
|
|
|
|
PAIR_LINE_WARN_ML,
|
|
|
|
|
PAIR_LINE_NORMAL_ML,
|
|
|
|
|
] as never,
|
|
|
|
|
'line-width': [
|
|
|
|
|
'case',
|
2026-02-17 10:52:51 +09:00
|
|
|
['==', ['get', 'highlighted'], 1], PAIR_LINE_W_HL,
|
|
|
|
|
['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN,
|
|
|
|
|
PAIR_LINE_W_NORMAL,
|
2026-02-16 23:44:19 +09:00
|
|
|
] as never,
|
|
|
|
|
'line-opacity': 0.9,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Pair lines layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
guardedSetVisibility(map, layerId, 'visible');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reorderGlobeFeatureLayers();
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stop = onMapStyleReady(map, ensure);
|
|
|
|
|
ensure();
|
|
|
|
|
return () => {
|
|
|
|
|
stop();
|
|
|
|
|
};
|
|
|
|
|
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
|
|
|
|
|
|
|
|
|
// Pair range
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = 'pair-range-ml-src';
|
|
|
|
|
const layerId = 'pair-range-ml';
|
|
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
guardedSetVisibility(map, layerId, 'none');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
|
|
|
|
if (projectionBusyRef.current) return;
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
|
|
|
|
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ranges: PairRangeCircle[] = [];
|
|
|
|
|
for (const p of pairLinks || []) {
|
|
|
|
|
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
|
|
|
|
ranges.push({
|
|
|
|
|
center,
|
|
|
|
|
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
|
|
|
|
warn: p.warn,
|
|
|
|
|
aMmsi: p.aMmsi,
|
|
|
|
|
bMmsi: p.bMmsi,
|
|
|
|
|
distanceNm: p.distanceNm,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (ranges.length === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: ranges.map((c) => {
|
|
|
|
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
|
|
|
|
return {
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
|
|
|
|
geometry: { type: 'LineString', coordinates: ring },
|
|
|
|
|
properties: {
|
|
|
|
|
type: 'pair-range',
|
|
|
|
|
warn: c.warn,
|
|
|
|
|
aMmsi: c.aMmsi,
|
|
|
|
|
bMmsi: c.bMmsi,
|
|
|
|
|
distanceNm: c.distanceNm,
|
|
|
|
|
highlighted: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
|
|
|
|
if (existing) existing.setData(fc);
|
|
|
|
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Pair range source setup failed:', e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(layerId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: layerId,
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: srcId,
|
2026-02-17 10:52:51 +09:00
|
|
|
layout: {
|
|
|
|
|
'line-cap': 'round',
|
|
|
|
|
'line-join': 'round',
|
|
|
|
|
visibility: 'visible',
|
|
|
|
|
},
|
2026-02-16 23:44:19 +09:00
|
|
|
paint: {
|
|
|
|
|
'line-color': [
|
|
|
|
|
'case',
|
|
|
|
|
['==', ['get', 'highlighted'], 1],
|
|
|
|
|
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
|
|
|
|
['boolean', ['get', 'warn'], false],
|
|
|
|
|
PAIR_RANGE_WARN_ML,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML,
|
|
|
|
|
] as never,
|
2026-02-17 10:52:51 +09:00
|
|
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
2026-02-16 23:44:19 +09:00
|
|
|
'line-opacity': 0.85,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Pair range layer add failed:', e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
guardedSetVisibility(map, layerId, 'visible');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stop = onMapStyleReady(map, ensure);
|
|
|
|
|
ensure();
|
|
|
|
|
return () => {
|
|
|
|
|
stop();
|
|
|
|
|
};
|
|
|
|
|
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
|
|
|
|
|
2026-02-17 10:52:51 +09:00
|
|
|
// Pair paint state updates + breathing animation
|
2026-02-16 23:44:19 +09:00
|
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
|
|
|
const updatePairPaintStates = useCallback(() => {
|
|
|
|
|
if (projection !== 'globe' || projectionBusyRef.current) return;
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || !map.isStyleLoaded()) return;
|
|
|
|
|
|
|
|
|
|
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
|
|
|
|
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
|
|
|
|
: false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer('pair-lines-ml')) {
|
|
|
|
|
map.setPaintProperty(
|
|
|
|
|
'pair-lines-ml', 'line-color',
|
|
|
|
|
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
|
|
|
|
);
|
|
|
|
|
map.setPaintProperty(
|
|
|
|
|
'pair-lines-ml', 'line-width',
|
2026-02-17 10:52:51 +09:00
|
|
|
['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
2026-02-16 23:44:19 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer('pair-range-ml')) {
|
|
|
|
|
map.setPaintProperty(
|
|
|
|
|
'pair-range-ml', 'line-color',
|
|
|
|
|
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
|
|
|
|
);
|
|
|
|
|
map.setPaintProperty(
|
|
|
|
|
'pair-range-ml', 'line-width',
|
2026-02-17 10:52:51 +09:00
|
|
|
['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
2026-02-16 23:44:19 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}, [projection, hoveredPairMmsiList]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
const stop = onMapStyleReady(map, updatePairPaintStates);
|
|
|
|
|
updatePairPaintStates();
|
|
|
|
|
return () => {
|
|
|
|
|
stop();
|
|
|
|
|
};
|
|
|
|
|
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
2026-02-17 10:52:51 +09:00
|
|
|
|
|
|
|
|
// Breathing animation for highlighted pair overlays
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
|
|
|
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
|
|
|
|
breatheRafRef.current = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pairHighlightExpr = makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList);
|
|
|
|
|
|
|
|
|
|
const animate = () => {
|
|
|
|
|
if (!map.isStyleLoaded()) {
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer('pair-lines-ml')) {
|
|
|
|
|
const hlW = PAIR_LINE_W_HL + t * BREATHE_AMP;
|
|
|
|
|
map.setPaintProperty('pair-lines-ml', 'line-width',
|
|
|
|
|
['case', pairHighlightExpr, hlW, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never);
|
|
|
|
|
}
|
|
|
|
|
if (map.getLayer('pair-range-ml')) {
|
|
|
|
|
const hlW = PAIR_RANGE_W_HL + t * BREATHE_AMP;
|
|
|
|
|
map.setPaintProperty('pair-range-ml', 'line-width',
|
|
|
|
|
['case', pairHighlightExpr, hlW, PAIR_RANGE_W_NORMAL] as never);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
};
|
|
|
|
|
breatheRafRef.current = requestAnimationFrame(animate);
|
|
|
|
|
return () => {
|
|
|
|
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
|
|
|
|
breatheRafRef.current = 0;
|
|
|
|
|
};
|
|
|
|
|
}, [hoveredPairMmsiList, projection]);
|
2026-02-16 23:44:19 +09:00
|
|
|
}
|