refactor(map3d): useGlobeOverlays 600줄 → 서브훅 2+1개 분리
- useGlobePairOverlay: pair lines + pair range + paint - useGlobeFcFleetOverlay: fc lines + fleet circles + paint - useGlobeOverlays: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
b1551f800b
커밋
4d67b26ffa
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, MapProjectionId } from '../types';
|
||||
import {
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
import {
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
makeMmsiAnyEndpointExpr,
|
||||
makeFleetOwnerMatchExpr,
|
||||
makeFleetMemberMatchExpr,
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
|
||||
/** Globe FC lines + fleet circles 오버레이 */
|
||||
export function useGlobeFcFleetOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
fcLinks: FcLink[] | undefined;
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredFleetMmsiList: number[];
|
||||
hoveredFleetOwnerKeyList: string[];
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fc-lines-ml-src';
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: segs.map((s, idx) => ({
|
||||
type: 'Feature',
|
||||
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||
properties: {
|
||||
type: 'fc',
|
||||
suspicious: s.suspicious,
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
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('FC 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', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'suspicious'], false],
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
hoveredPairMmsiList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Fleet circles
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
guardedSetVisibility(map, fillLayerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const circles = fleetCircles || [];
|
||||
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
|
||||
hoveredFleetOwnerKeyList.includes(ownerKey) ||
|
||||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'fleet',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles
|
||||
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
||||
.map((c) => ({
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
||||
},
|
||||
properties: {
|
||||
ownerKey: c.ownerKey,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill source setup failed:', e);
|
||||
}
|
||||
|
||||
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], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
|
||||
paint: {
|
||||
'fill-color': FLEET_FILL_ML_HL,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
hoveredFleetOwnerKeyList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// FC + Fleet paint state updates
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateFcFleetPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||
: false;
|
||||
|
||||
const fleetOwnerMatchExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||
const fleetMemberExpr =
|
||||
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||
const fleetHighlightExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: false;
|
||||
|
||||
try {
|
||||
if (map.getLayer('fc-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateFcFleetPaintStates);
|
||||
updateFcFleetPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
||||
}
|
||||
@ -1,35 +1,10 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, 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,
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
import {
|
||||
makePairLinkFeatureId,
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
makeMmsiPairHighlightExpr,
|
||||
makeMmsiAnyEndpointExpr,
|
||||
makeFleetOwnerMatchExpr,
|
||||
makeFleetMemberMatchExpr,
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { useGlobePairOverlay } from './useGlobePairOverlay';
|
||||
import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay';
|
||||
|
||||
export function useGlobeOverlays(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -47,554 +22,24 @@ export function useGlobeOverlays(
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
// Pair lines + pair range
|
||||
useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
pairLinks: opts.pairLinks,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
|
||||
// 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',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
] 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]);
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fc-lines-ml-src';
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: segs.map((s, idx) => ({
|
||||
type: 'Feature',
|
||||
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||
properties: {
|
||||
type: 'fc',
|
||||
suspicious: s.suspicious,
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
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('FC 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', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'suspicious'], false],
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
hoveredPairMmsiList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
|
||||
// Fleet circles
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||
// 라인만으로 fleet circle 시각화 충분
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
guardedSetVisibility(map, fillLayerId, 'none');
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const circles = fleetCircles || [];
|
||||
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
|
||||
hoveredFleetOwnerKeyList.includes(ownerKey) ||
|
||||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'fleet',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: circles
|
||||
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
||||
.map((c) => ({
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
||||
},
|
||||
properties: {
|
||||
ownerKey: c.ownerKey,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill source setup failed:', e);
|
||||
}
|
||||
|
||||
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], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
|
||||
paint: {
|
||||
'fill-color': FLEET_FILL_ML_HL,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
hoveredFleetOwnerKeyList,
|
||||
hoveredFleetMmsiList,
|
||||
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,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
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,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||
'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]);
|
||||
|
||||
// Paint state updates for hover highlights
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateGlobeOverlayPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: false;
|
||||
|
||||
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||
: false;
|
||||
|
||||
const fleetOwnerMatchExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||
const fleetMemberExpr =
|
||||
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||
const fleetHighlightExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: 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',
|
||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fc-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||
);
|
||||
}
|
||||
} 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',
|
||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
|
||||
updateGlobeOverlayPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
|
||||
// FC lines + fleet circles
|
||||
useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
fcLinks: opts.fcLinks,
|
||||
fleetCircles: opts.fleetCircles,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredFleetMmsiList: opts.hoveredFleetMmsiList,
|
||||
hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
}
|
||||
|
||||
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
|
||||
// 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',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
] 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,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
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,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||
'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]);
|
||||
|
||||
// Pair paint state updates
|
||||
// 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',
|
||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||
);
|
||||
}
|
||||
} 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',
|
||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updatePairPaintStates);
|
||||
updatePairPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user