gc-wing/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
htlee 95d9ea8aef fix(map): 라벨 사라짐 + easing 경고 + vertex 경고 수정
- guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty
  호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한
  text-allow-overlap:false 라벨 사라짐 현상 해결
- useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거
  (data-driven 표현식은 addLayer 시 1회 설정으로 충분)
- _render 래퍼에서 globe scrollZoom easing 경고 억제
- fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:42 +09:00

525 lines
17 KiB
TypeScript

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, 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_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';
export function useGlobeOverlays(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
pairLinks: PairLink[] | undefined;
fcLinks: FcLink[] | undefined;
fleetCircles: FleetCircle[] | undefined;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredFleetMmsiList: number[];
hoveredFleetOwnerKeyList: string[];
hoveredPairMmsiList: number[];
},
) {
const {
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, 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;
if (projection !== 'globe' || !overlays.pairLines || (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, 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;
if (projection !== 'globe' || !overlays.fcLines) {
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, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Fleet circles
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const layerId = 'fleet-circles-ml';
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
// 라인만으로 fleet circle 시각화 충분
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
remove();
return;
}
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (fleetCircles || []).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,
},
};
}),
};
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;
}
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');
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.fleetCircles, fleetCircles, 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;
if (projection !== 'globe' || !overlays.pairRange) {
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, 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]);
}