fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선
- isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지) - Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지) - Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거 - 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경 - useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
ccaf20804d
커밋
7bca216c53
@ -1,7 +1,6 @@
|
||||
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants';
|
||||
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
|
||||
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
|
||||
import { getShipKindColor } from '../lib/adapters';
|
||||
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
||||
|
||||
@ -139,20 +138,21 @@ export function createDynamicTrackLayers(options: {
|
||||
|
||||
if (showVirtualShip) {
|
||||
layers.push(
|
||||
new IconLayer<CurrentVesselPosition>({
|
||||
new ScatterplotLayer<CurrentVesselPosition>({
|
||||
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
||||
data: currentPositions,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => d.position,
|
||||
getSize: 22,
|
||||
sizeUnits: 'pixels',
|
||||
getAngle: (d) => -d.heading,
|
||||
getColor: (d) => {
|
||||
getFillColor: (d) => {
|
||||
const base = getShipKindColor(d.shipKindCode);
|
||||
return [base[0], base[1], base[2], 245] as [number, number, number, number];
|
||||
return [base[0], base[1], base[2], 230] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getRadius: 5,
|
||||
radiusUnits: 'pixels',
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
stroked: true,
|
||||
lineWidthMinPixels: 1,
|
||||
parameters: DEPTH_DISABLED_PARAMS,
|
||||
pickable: true,
|
||||
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
||||
@ -183,6 +183,7 @@ export function createDynamicTrackLayers(options: {
|
||||
getAlignmentBaseline: 'center',
|
||||
getPixelOffset: [14, 0],
|
||||
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
||||
fontSettings: { sdf: true },
|
||||
outlineColor: [2, 6, 23, 220],
|
||||
outlineWidth: 2,
|
||||
parameters: DEPTH_DISABLED_PARAMS,
|
||||
|
||||
@ -238,12 +238,15 @@ export function Map3D({
|
||||
return out;
|
||||
}, [highlightedMmsiSetForShips, selectedMmsi]);
|
||||
|
||||
// Globe: 직접 호버/선택된 선박만 hover overlay에 포함
|
||||
// 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요
|
||||
// → alarm badge 레이어 가림 방지
|
||||
const shipHoverOverlaySet = useMemo(
|
||||
() =>
|
||||
projection === 'globe'
|
||||
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
|
||||
? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef)
|
||||
: shipHighlightSet,
|
||||
[projection, highlightedMmsiSetCombined, shipHighlightSet],
|
||||
[projection, shipHighlightSet, hoveredDeckMmsiSetRef],
|
||||
);
|
||||
|
||||
const shipOverlayLayerData = useMemo(() => {
|
||||
@ -598,7 +601,7 @@ export function Map3D({
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
||||
ensureMercatorOverlay, alarmMmsiMap,
|
||||
},
|
||||
);
|
||||
|
||||
@ -633,22 +636,30 @@ export function Map3D({
|
||||
e.preventDefault();
|
||||
if (!onOpenTrackMenu) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
|
||||
if (!map || projectionBusyRef.current) return;
|
||||
|
||||
let mmsi: number | null = null;
|
||||
|
||||
if (projectionRef.current === 'globe') {
|
||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
||||
const shipLayerIds = [
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
].filter((id) => map.getLayer(id));
|
||||
// Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관)
|
||||
let shipLayerIds: string[] = [];
|
||||
try {
|
||||
shipLayerIds = projectionRef.current === 'globe'
|
||||
? [
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||
].filter((id) => map.getLayer(id))
|
||||
: [];
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (shipLayerIds.length > 0) {
|
||||
const tolerance = 8;
|
||||
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||
[e.offsetX - tolerance, e.offsetY - tolerance],
|
||||
[e.offsetX + tolerance, e.offsetY + tolerance],
|
||||
];
|
||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||
try {
|
||||
if (shipLayerIds.length > 0) {
|
||||
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
|
||||
}
|
||||
features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds });
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (features.length > 0) {
|
||||
@ -656,8 +667,10 @@ export function Map3D({
|
||||
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
||||
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
||||
}
|
||||
} else {
|
||||
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
|
||||
}
|
||||
|
||||
// Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조
|
||||
if (mmsi == null && projectionRef.current !== 'globe') {
|
||||
const hovered = hoveredDeckMmsiRef.current;
|
||||
if (hovered.length > 0) mmsi = hovered[0];
|
||||
}
|
||||
|
||||
@ -68,7 +68,6 @@ export function useDeckLayers(
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
projectionRef: MutableRefObject<MapProjectionId>;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
},
|
||||
) {
|
||||
@ -82,7 +81,7 @@ export function useDeckLayers(
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
||||
ensureMercatorOverlay, alarmMmsiMap,
|
||||
} = opts;
|
||||
|
||||
const legacyTargets = useMemo(() => {
|
||||
@ -219,12 +218,6 @@ export function useDeckLayers(
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(t.mmsi);
|
||||
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||
if (projectionRef.current === 'globe') {
|
||||
map.flyTo(clickOpts);
|
||||
} else {
|
||||
map.easeTo(clickOpts);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -15,21 +15,7 @@ export function useFlyTo(
|
||||
fleetFocusZoom: number | undefined;
|
||||
},
|
||||
) {
|
||||
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!selectedMmsi) return;
|
||||
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
||||
if (!t) return;
|
||||
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||
if (projectionRef.current === 'globe') {
|
||||
map.flyTo(flyOpts);
|
||||
} else {
|
||||
map.easeTo(flyOpts);
|
||||
}
|
||||
}, [selectedMmsi, shipData]);
|
||||
const { fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
|
||||
@ -7,7 +7,6 @@ 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';
|
||||
@ -22,7 +21,6 @@ import {
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
|
||||
// ── Overlay line width constants ──
|
||||
@ -35,10 +33,10 @@ const FLEET_LINE_W_HL = 3.0;
|
||||
const BREATHE_AMP = 2.0;
|
||||
const BREATHE_PERIOD_MS = 1200;
|
||||
|
||||
/** Globe FC lines + fleet circles 오버레이 */
|
||||
/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */
|
||||
export function useGlobeFcFleetOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
_projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
@ -57,7 +55,12 @@ export function useGlobeFcFleetOverlay(
|
||||
} = opts;
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
|
||||
// FC lines
|
||||
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용
|
||||
const paintStateRef = useRef<() => void>(() => {});
|
||||
|
||||
// ── FC lines 데이터 effect ──
|
||||
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
@ -65,17 +68,11 @@ export function useGlobeFcFleetOverlay(
|
||||
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();
|
||||
if (projection !== 'globe') {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -84,7 +81,9 @@ export function useGlobeFcFleetOverlay(
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -108,12 +107,12 @@ export function useGlobeFcFleetOverlay(
|
||||
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;
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
const needReorder = !map.getLayer(layerId);
|
||||
if (needReorder) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -122,74 +121,46 @@ export function useGlobeFcFleetOverlay(
|
||||
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], FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
||||
'line-opacity': 0.9,
|
||||
'line-color': FC_LINE_NORMAL_ML,
|
||||
'line-width': FC_LINE_W_NORMAL,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
paintStateRef.current();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fcLines,
|
||||
fcLinks,
|
||||
hoveredPairMmsiList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
return () => { stop(); };
|
||||
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Fleet circles
|
||||
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
|
||||
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();
|
||||
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
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',
|
||||
@ -205,47 +176,21 @@ export function useGlobeFcFleetOverlay(
|
||||
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;
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
|
||||
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)) {
|
||||
const needReorder = !map.getLayer(layerId);
|
||||
if (needReorder) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -254,66 +199,34 @@ export function useGlobeFcFleetOverlay(
|
||||
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], FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never,
|
||||
'line-opacity': 0.85,
|
||||
'line-color': FLEET_LINE_ML,
|
||||
'line-width': FLEET_LINE_W_NORMAL,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
|
||||
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();
|
||||
paintStateRef.current();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
overlays.fleetCircles,
|
||||
fleetCircles,
|
||||
hoveredFleetOwnerKeyList,
|
||||
hoveredFleetMmsiList,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
return () => { stop(); };
|
||||
}, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// FC + Fleet paint state updates
|
||||
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateFcFleetPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
if (projection !== 'globe') return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (!map) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
@ -330,16 +243,29 @@ export function useGlobeFcFleetOverlay(
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: false;
|
||||
|
||||
// ── FC lines ──
|
||||
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
const fcVisible = overlays.fcLines || pairActive;
|
||||
// ── Fleet circles ──
|
||||
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
const fleetVisible = overlays.fleetCircles || fleetActive;
|
||||
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, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
||||
);
|
||||
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
|
||||
if (fcVisible) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
fcEndpointHighlightExpr !== false
|
||||
? ['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
|
||||
: ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
fcEndpointHighlightExpr !== false
|
||||
? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never
|
||||
: FC_LINE_W_NORMAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@ -347,25 +273,38 @@ export function useGlobeFcFleetOverlay(
|
||||
|
||||
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, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0);
|
||||
if (fleetVisible) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color',
|
||||
fleetHighlightExpr !== false
|
||||
? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never
|
||||
: FLEET_LINE_ML,
|
||||
);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width',
|
||||
fleetHighlightExpr !== false
|
||||
? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never
|
||||
: FLEET_LINE_W_NORMAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
kickRepaint(map);
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]);
|
||||
|
||||
// paintStateRef를 최신 콜백으로 유지
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateFcFleetPaintStates);
|
||||
updateFcFleetPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
||||
paintStateRef.current = updateFcFleetPaintStates;
|
||||
}, [updateFcFleetPaintStates]);
|
||||
|
||||
// Breathing animation for highlighted fc/fleet overlays
|
||||
// paint state 동기화
|
||||
useEffect(() => {
|
||||
updateFcFleetPaintStates();
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]);
|
||||
|
||||
// ── Breathing animation ──
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||
|
||||
@ -14,6 +14,10 @@ import {
|
||||
} from '../lib/tooltips';
|
||||
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||
|
||||
// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환.
|
||||
// 즉시 clear 대신 딜레이를 두어 깜박임 방지.
|
||||
const TOOLTIP_CLEAR_DELAY_MS = 150;
|
||||
|
||||
export function useGlobeInteraction(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
@ -57,7 +61,7 @@ export function useGlobeInteraction(
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (!map) return;
|
||||
if (!mapTooltipRef.current) {
|
||||
mapTooltipRef.current = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
@ -74,6 +78,12 @@ export function useGlobeInteraction(
|
||||
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||
}, []);
|
||||
|
||||
// buildGlobeFeatureTooltip을 ref로 관리 — legacyHits/shipByMmsi가 매 AIS poll마다 변경되므로
|
||||
// useCallback 의존성으로 넣으면 effect가 재실행되어 cleanup에서 tooltip이 제거됨
|
||||
// ref로 관리하면 effect 재실행 없이 항상 최신 함수 참조
|
||||
type TooltipFeature = { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined;
|
||||
const buildTooltipRef = useRef<(feature: TooltipFeature) => { html: string } | null>(() => null);
|
||||
|
||||
const buildGlobeFeatureTooltip = useCallback(
|
||||
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||
if (!feature) return null;
|
||||
@ -136,17 +146,14 @@ export function useGlobeInteraction(
|
||||
[legacyHits, shipByMmsi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
buildTooltipRef.current = buildGlobeFeatureTooltip;
|
||||
}, [buildGlobeFeatureTooltip]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const clearDeckGlobeHoverState = () => {
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
|
||||
const resetGlobeHoverStates = () => {
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
@ -155,36 +162,52 @@ export function useGlobeInteraction(
|
||||
};
|
||||
|
||||
const normalizeMmsiList = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
let arr = value;
|
||||
// MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음
|
||||
if (typeof arr === 'string') {
|
||||
try { arr = JSON.parse(arr); } catch { return []; }
|
||||
}
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out: number[] = [];
|
||||
for (const n of value) {
|
||||
for (const n of arr) {
|
||||
const m = toIntMmsi(n);
|
||||
if (m != null) out.push(m);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 지연 clear 타이머 — setData() 타일 재빌드 중 일시적 빈 결과를 무시
|
||||
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleClear = () => {
|
||||
if (clearTimer) return; // 이미 예약됨
|
||||
clearTimer = setTimeout(() => {
|
||||
clearTimer = null;
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
}, TOOLTIP_CLEAR_DELAY_MS);
|
||||
};
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; }
|
||||
};
|
||||
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
if (projection !== 'globe') {
|
||||
cancelClear();
|
||||
clearGlobeTooltip();
|
||||
resetGlobeHoverStates();
|
||||
return;
|
||||
}
|
||||
if (projectionBusyRef.current) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
if (!map.isStyleLoaded()) {
|
||||
clearDeckGlobeHoverState();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임)
|
||||
}
|
||||
|
||||
let candidateLayerIds: string[] = [];
|
||||
try {
|
||||
candidateLayerIds = [
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||
'pair-lines-ml', 'fc-lines-ml',
|
||||
'fleet-circles-ml',
|
||||
'pair-range-ml',
|
||||
@ -195,14 +218,18 @@ export function useGlobeInteraction(
|
||||
}
|
||||
|
||||
if (candidateLayerIds.length === 0) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
scheduleClear();
|
||||
return;
|
||||
}
|
||||
|
||||
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||
try {
|
||||
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
||||
const tolerance = 10;
|
||||
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||
[e.point.x - tolerance, e.point.y - tolerance],
|
||||
[e.point.x + tolerance, e.point.y + tolerance],
|
||||
];
|
||||
rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{
|
||||
properties?: Record<string, unknown> | null;
|
||||
layer?: { id?: string };
|
||||
}>;
|
||||
@ -212,6 +239,7 @@ export function useGlobeInteraction(
|
||||
|
||||
const priority = [
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||
'fleet-circles-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
@ -222,18 +250,23 @@ export function useGlobeInteraction(
|
||||
| undefined;
|
||||
|
||||
if (!first) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
// 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear
|
||||
scheduleClear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 피처 발견 — 지연 clear 취소
|
||||
cancelClear();
|
||||
|
||||
const layerId = first.layer?.id;
|
||||
const props = first.properties || {};
|
||||
const isShipLayer =
|
||||
layerId === 'ships-globe' ||
|
||||
layerId === 'ships-globe-lite' ||
|
||||
layerId === 'ships-globe-halo' ||
|
||||
layerId === 'ships-globe-outline';
|
||||
layerId === 'ships-globe-outline' ||
|
||||
layerId === 'ships-globe-alarm-pulse' ||
|
||||
layerId === 'ships-globe-alarm-badge';
|
||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||
const isFcLayer = layerId === 'fc-lines-ml';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||
@ -277,7 +310,7 @@ export function useGlobeInteraction(
|
||||
resetGlobeHoverStates();
|
||||
}
|
||||
|
||||
const tooltip = buildGlobeFeatureTooltip(first);
|
||||
const tooltip = buildTooltipRef.current(first);
|
||||
if (!tooltip) {
|
||||
if (!isZoneLayer) {
|
||||
resetGlobeHoverStates();
|
||||
@ -295,6 +328,7 @@ export function useGlobeInteraction(
|
||||
};
|
||||
|
||||
const onMouseOut = () => {
|
||||
cancelClear();
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
};
|
||||
@ -303,13 +337,14 @@ export function useGlobeInteraction(
|
||||
map.on('mouseout', onMouseOut);
|
||||
|
||||
return () => {
|
||||
cancelClear();
|
||||
map.off('mousemove', onMouseMove);
|
||||
map.off('mouseout', onMouseOut);
|
||||
clearGlobeTooltip();
|
||||
// cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가
|
||||
// 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리.
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
buildGlobeFeatureTooltip,
|
||||
clearGlobeTooltip,
|
||||
clearMapFleetHoverState,
|
||||
clearDeckHoverPairs,
|
||||
@ -319,4 +354,9 @@ export function useGlobeInteraction(
|
||||
setMapFleetHoverState,
|
||||
setGlobeTooltip,
|
||||
]);
|
||||
|
||||
// 컴포넌트 unmount 시에만 tooltip 제거
|
||||
useEffect(() => {
|
||||
return () => { clearGlobeTooltip(); };
|
||||
}, [clearGlobeTooltip]);
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ 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';
|
||||
|
||||
// ── Overlay line width constants ──
|
||||
const PAIR_LINE_W_NORMAL = 2.5;
|
||||
@ -30,7 +29,7 @@ const BREATHE_PERIOD_MS = 1200;
|
||||
/** Globe pair lines + pair range 오버레이 */
|
||||
export function useGlobePairOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
_projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
@ -43,7 +42,12 @@ export function useGlobePairOverlay(
|
||||
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
|
||||
// Pair lines
|
||||
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용
|
||||
const paintStateRef = useRef<() => void>(() => {});
|
||||
|
||||
// ── Pair lines 데이터 effect ──
|
||||
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
@ -51,16 +55,17 @@ export function useGlobePairOverlay(
|
||||
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();
|
||||
if (projection !== 'globe') {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if ((pairLinks?.length ?? 0) === 0) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -84,12 +89,12 @@ export function useGlobePairOverlay(
|
||||
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;
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
const needReorder = !map.getLayer(layerId);
|
||||
if (needReorder) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -98,44 +103,31 @@ export function useGlobePairOverlay(
|
||||
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], PAIR_LINE_W_HL,
|
||||
['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN,
|
||||
PAIR_LINE_W_NORMAL,
|
||||
] as never,
|
||||
'line-opacity': 0.9,
|
||||
'line-color': PAIR_LINE_NORMAL_ML,
|
||||
'line-width': PAIR_LINE_W_NORMAL,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines layer add failed:', e);
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
// 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지
|
||||
paintStateRef.current();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
return () => { stop(); };
|
||||
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Pair range
|
||||
// ── Pair range 데이터 effect ──
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
@ -143,16 +135,11 @@ export function useGlobePairOverlay(
|
||||
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();
|
||||
if (projection !== 'globe') {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -169,7 +156,9 @@ export function useGlobePairOverlay(
|
||||
});
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
remove();
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -187,7 +176,6 @@ export function useGlobePairOverlay(
|
||||
aMmsi: c.aMmsi,
|
||||
bMmsi: c.bMmsi,
|
||||
distanceNm: c.distanceNm,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@ -197,12 +185,12 @@ export function useGlobePairOverlay(
|
||||
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;
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
const needReorder = !map.getLayer(layerId);
|
||||
if (needReorder) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -215,90 +203,105 @@ export function useGlobePairOverlay(
|
||||
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], PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
||||
'line-opacity': 0.85,
|
||||
'line-color': PAIR_RANGE_NORMAL_ML,
|
||||
'line-width': PAIR_RANGE_W_NORMAL,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair range layer add failed:', e);
|
||||
} catch {
|
||||
return; // 다음 poll에서 재시도
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
|
||||
paintStateRef.current();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
return () => { stop(); };
|
||||
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Pair paint state updates + breathing animation
|
||||
// ── Pair paint state update (가시성 + 하이라이트 통합) ──
|
||||
// setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어
|
||||
// → style._changed 미트리거 → alarm badge symbol placement 재계산 방지
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updatePairPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
if (projection !== 'globe') return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (!map) return;
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
const active = hoveredPairMmsiList.length >= 2;
|
||||
const pairHighlightExpr = active
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: false;
|
||||
|
||||
// ── Pair lines: 가시성 + 하이라이트 ──
|
||||
const pairLinesVisible = overlays.pairLines || active;
|
||||
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, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
||||
);
|
||||
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
|
||||
if (pairLinesVisible) {
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-color',
|
||||
pairHighlightExpr !== false
|
||||
? ['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
|
||||
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-width',
|
||||
pairHighlightExpr !== false
|
||||
? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never
|
||||
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// ── Pair range: 가시성 + 하이라이트 ──
|
||||
const pairRangeVisible = overlays.pairRange || active;
|
||||
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, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
||||
);
|
||||
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
|
||||
if (pairRangeVisible) {
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-color',
|
||||
pairHighlightExpr !== false
|
||||
? ['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
|
||||
: ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-width',
|
||||
pairHighlightExpr !== false
|
||||
? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never
|
||||
: PAIR_RANGE_W_NORMAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredPairMmsiList]);
|
||||
|
||||
kickRepaint(map);
|
||||
}, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]);
|
||||
|
||||
// paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updatePairPaintStates);
|
||||
updatePairPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
||||
paintStateRef.current = updatePairPaintStates;
|
||||
}, [updatePairPaintStates]);
|
||||
|
||||
// Breathing animation for highlighted pair overlays
|
||||
// paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영
|
||||
useEffect(() => {
|
||||
updatePairPaintStates();
|
||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]);
|
||||
|
||||
// ── Breathing animation for highlighted pair overlays ──
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
DEG2RAD,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
|
||||
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
|
||||
@ -19,7 +19,7 @@ import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
/** Globe 호버 오버레이 + 클릭 선택 */
|
||||
export function useGlobeShipHover(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
_projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
@ -62,9 +62,6 @@ export function useGlobeShipHover(
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||
hideHover();
|
||||
return;
|
||||
@ -74,7 +71,9 @@ export function useGlobeShipHover(
|
||||
epochRef.current = mapSyncEpoch;
|
||||
}
|
||||
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
try {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
} catch { /* ignore */ }
|
||||
if (!map.hasImage(imgId)) {
|
||||
return;
|
||||
}
|
||||
@ -166,7 +165,7 @@ export function useGlobeShipHover(
|
||||
] as never,
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
||||
@ -181,7 +180,7 @@ export function useGlobeShipHover(
|
||||
console.warn('Ship hover halo layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(haloId, 'visibility', 'visible');
|
||||
guardedSetVisibility(map, haloId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
@ -192,7 +191,7 @@ export function useGlobeShipHover(
|
||||
type: 'circle',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
@ -222,7 +221,7 @@ export function useGlobeShipHover(
|
||||
console.warn('Ship hover outline layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(outlineId, 'visibility', 'visible');
|
||||
guardedSetVisibility(map, outlineId, 'visible');
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
@ -267,7 +266,7 @@ export function useGlobeShipHover(
|
||||
console.warn('Ship hover symbol layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
map.setLayoutProperty(symbolId, 'visibility', 'visible');
|
||||
guardedSetVisibility(map, symbolId, 'visible');
|
||||
}
|
||||
|
||||
if (needReorder) {
|
||||
@ -301,15 +300,20 @@ export function useGlobeShipHover(
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
||||
const clickedRadiusDeg2 = Math.pow(0.12, 2);
|
||||
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
try {
|
||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id));
|
||||
let feats: unknown[] = [];
|
||||
if (layerIds.length > 0) {
|
||||
try {
|
||||
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
|
||||
const tolerance = 10;
|
||||
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||
[e.point.x - tolerance, e.point.y - tolerance],
|
||||
[e.point.x + tolerance, e.point.y + tolerance],
|
||||
];
|
||||
feats = map.queryRenderedFeatures(bbox, { layers: layerIds }) as unknown[];
|
||||
} catch {
|
||||
feats = [];
|
||||
}
|
||||
|
||||
@ -60,6 +60,8 @@ export function useGlobeShipLayers(
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||
const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||
|
||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||
@ -83,16 +85,13 @@ export function useGlobeShipLayers(
|
||||
50, 420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||
const selectedScale = selected ? 1.08 : 1;
|
||||
const highlightScale = highlighted ? 1.06 : 1;
|
||||
const iconScale = selected ? selectedScale : highlightScale;
|
||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
||||
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
||||
// hover overlay 레이어가 확대 + z-priority를 담당
|
||||
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||
const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
|
||||
const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
@ -109,14 +108,12 @@ export function useGlobeShipLayers(
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: iconSize3 * iconScale,
|
||||
iconSize7: iconSize7 * iconScale,
|
||||
iconSize10: iconSize10 * iconScale,
|
||||
iconSize14: iconSize14 * iconScale,
|
||||
iconSize18: iconSize18 * iconScale,
|
||||
iconSize3,
|
||||
iconSize7,
|
||||
iconSize10,
|
||||
iconSize14,
|
||||
iconSize18,
|
||||
sizeScale,
|
||||
selected: selected ? 1 : 0,
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
code: legacy?.shipCode || '',
|
||||
alarmed: alarmKind ? 1 : 0,
|
||||
@ -127,7 +124,7 @@ export function useGlobeShipLayers(
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi, alarmMmsiMap]);
|
||||
}, [shipData, legacyHits, alarmMmsiMap]);
|
||||
|
||||
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
||||
// when the main ship source updates (position polling)
|
||||
@ -141,23 +138,20 @@ export function useGlobeShipLayers(
|
||||
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
||||
.map((t) => {
|
||||
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
alarmed: 1,
|
||||
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
||||
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
||||
selected: selected ? 1 : 0,
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [shipData, alarmMmsiMap, selectedMmsi, isBaseHighlightedMmsi]);
|
||||
}, [shipData, alarmMmsiMap]);
|
||||
|
||||
// Ships in globe mode
|
||||
useEffect(() => {
|
||||
@ -235,12 +229,18 @@ export function useGlobeShipLayers(
|
||||
}
|
||||
|
||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||
// 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
|
||||
const geojson = globeShipGeoJson;
|
||||
const geoJsonChanged = geojson !== prevGeoJsonRef.current;
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(geojson);
|
||||
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||
if (existing) {
|
||||
if (geoJsonChanged) existing.setData(geojson);
|
||||
} else {
|
||||
map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||
}
|
||||
prevGeoJsonRef.current = geojson;
|
||||
} catch (e) {
|
||||
console.warn('Ship source setup failed:', e);
|
||||
return;
|
||||
@ -249,27 +249,32 @@ export function useGlobeShipLayers(
|
||||
// Alarm source — isolated from main source for stable badge rendering
|
||||
try {
|
||||
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
||||
if (existingAlarm) existingAlarm.setData(alarmGeoJson);
|
||||
else map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
||||
const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
|
||||
if (existingAlarm) {
|
||||
if (alarmChanged) existingAlarm.setData(alarmGeoJson);
|
||||
} else {
|
||||
map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
||||
}
|
||||
prevAlarmGeoJsonRef.current = alarmGeoJson;
|
||||
} catch (e) {
|
||||
console.warn('Alarm source setup failed:', e);
|
||||
}
|
||||
|
||||
const before = undefined;
|
||||
let needReorder = false;
|
||||
const priorityFilter = [
|
||||
'any',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 1],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 1],
|
||||
['==', ['to-number', ['get', 'alarmed'], 0], 1],
|
||||
] as unknown as unknown[];
|
||||
const nonPriorityFilter = [
|
||||
'all',
|
||||
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
||||
['==', ['to-number', ['get', 'selected'], 0], 0],
|
||||
['==', ['to-number', ['get', 'highlighted'], 0], 0],
|
||||
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -280,12 +285,8 @@ export function useGlobeShipLayers(
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
||||
['==', ['get', 'permitted'], 1], 110,
|
||||
['==', ['get', 'selected'], 1], 60,
|
||||
['==', ['get', 'highlighted'], 1], 55,
|
||||
['==', ['get', 'alarmed'], 1], 22,
|
||||
20,
|
||||
] as never,
|
||||
@ -295,8 +296,8 @@ export function useGlobeShipLayers(
|
||||
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'circle-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 0.38,
|
||||
['==', ['get', 'highlighted'], 1], 0.34,
|
||||
['==', ['feature-state', 'selected'], 1], 0.38,
|
||||
['==', ['feature-state', 'highlighted'], 1], 0.34,
|
||||
0.16,
|
||||
] as never,
|
||||
},
|
||||
@ -309,6 +310,7 @@ export function useGlobeShipLayers(
|
||||
}
|
||||
|
||||
if (!map.getLayer(outlineId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -320,15 +322,15 @@ export function useGlobeShipLayers(
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||
GLOBE_OUTLINE_OTHER,
|
||||
] as never,
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 3.4,
|
||||
['==', ['get', 'highlighted'], 1], 2.7,
|
||||
['==', ['feature-state', 'selected'], 1], 3.4,
|
||||
['==', ['feature-state', 'highlighted'], 1], 2.7,
|
||||
['==', ['get', 'permitted'], 1], 1.8,
|
||||
0.7,
|
||||
] as never,
|
||||
@ -338,12 +340,8 @@ export function useGlobeShipLayers(
|
||||
visibility,
|
||||
'circle-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
||||
['==', ['get', 'permitted'], 1], 120,
|
||||
['==', ['get', 'selected'], 1], 70,
|
||||
['==', ['get', 'highlighted'], 1], 65,
|
||||
['==', ['get', 'alarmed'], 1], 32,
|
||||
30,
|
||||
] as never,
|
||||
@ -359,6 +357,7 @@ export function useGlobeShipLayers(
|
||||
// Alarm pulse circle (above outline, below ship icons)
|
||||
// Uses separate alarm source for stable rendering
|
||||
if (!map.getLayer(pulseId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -382,6 +381,7 @@ export function useGlobeShipLayers(
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolLiteId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -451,6 +451,7 @@ export function useGlobeShipLayers(
|
||||
}
|
||||
|
||||
if (!map.getLayer(symbolId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -462,12 +463,8 @@ export function useGlobeShipLayers(
|
||||
visibility,
|
||||
'symbol-sort-key': [
|
||||
'case',
|
||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
||||
['==', ['get', 'permitted'], 1], 130,
|
||||
['==', ['get', 'selected'], 1], 80,
|
||||
['==', ['get', 'highlighted'], 1], 75,
|
||||
['==', ['get', 'alarmed'], 1], 47,
|
||||
45,
|
||||
] as never,
|
||||
@ -500,8 +497,8 @@ export function useGlobeShipLayers(
|
||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||
'icon-opacity': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 1,
|
||||
['==', ['get', 'highlighted'], 1], 0.95,
|
||||
['==', ['feature-state', 'selected'], 1], 1,
|
||||
['==', ['feature-state', 'highlighted'], 1], 0.95,
|
||||
['==', ['get', 'permitted'], 1], 0.93,
|
||||
0.9,
|
||||
] as never,
|
||||
@ -517,15 +514,11 @@ export function useGlobeShipLayers(
|
||||
const labelFilter = [
|
||||
'all',
|
||||
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
||||
[
|
||||
'any',
|
||||
['==', ['get', 'permitted'], 1],
|
||||
['==', ['get', 'selected'], 1],
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
],
|
||||
['==', ['get', 'permitted'], 1],
|
||||
] as unknown as unknown[];
|
||||
|
||||
if (!map.getLayer(labelId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -549,8 +542,8 @@ export function useGlobeShipLayers(
|
||||
paint: {
|
||||
'text-color': [
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
'rgba(226,232,240,0.92)',
|
||||
] as never,
|
||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||
@ -568,6 +561,7 @@ export function useGlobeShipLayers(
|
||||
// Alarm badge symbol (above labels)
|
||||
// Uses separate alarm source for stable rendering
|
||||
if (!map.getLayer(badgeId)) {
|
||||
needReorder = true;
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
@ -600,7 +594,9 @@ export function useGlobeShipLayers(
|
||||
|
||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||
onGlobeShipsReady?.(true);
|
||||
if (projection === 'globe') {
|
||||
// needReorder: 새 레이어가 생성된 경우에만 reorder 호출
|
||||
// 매 AIS poll마다 28개 moveLayer → style._changed 방지
|
||||
if (projection === 'globe' && needReorder) {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
kickRepaint(map);
|
||||
@ -616,14 +612,47 @@ export function useGlobeShipLayers(
|
||||
overlays.shipLabels,
|
||||
globeShipGeoJson,
|
||||
alarmGeoJson,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
onGlobeShipsReady,
|
||||
alarmMmsiMap,
|
||||
]);
|
||||
|
||||
// Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const src = 'ships-globe-src';
|
||||
const alarmSrc = 'ships-globe-alarm-src';
|
||||
for (const t of shipData) {
|
||||
if (!isFiniteNumber(t.mmsi)) continue;
|
||||
const id = Math.trunc(t.mmsi);
|
||||
const s = t.mmsi === selectedMmsi ? 1 : 0;
|
||||
const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
|
||||
try {
|
||||
map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (map.getSource(alarmSrc) && alarmMmsiMap) {
|
||||
for (const t of shipData) {
|
||||
if (!alarmMmsiMap.has(t.mmsi)) continue;
|
||||
const id = Math.trunc(t.mmsi);
|
||||
try {
|
||||
map.setFeatureState(
|
||||
{ source: alarmSrc, id },
|
||||
{ selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
kickRepaint(map);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);
|
||||
|
||||
// Alarm pulse breathing animation (rAF)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@ -645,7 +674,7 @@ export function useGlobeShipLayers(
|
||||
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||
'case',
|
||||
['any', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'selected'], 1]],
|
||||
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
||||
hoverR,
|
||||
normalR,
|
||||
] as never);
|
||||
|
||||
@ -108,7 +108,9 @@ export function useNativeMapLayers(
|
||||
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||
const hasData = cfg.sources.some((s) => s.data != null);
|
||||
if (!hasData) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
// isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해
|
||||
// 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지.
|
||||
// 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도.
|
||||
|
||||
try {
|
||||
// 3. Source 생성/업데이트
|
||||
|
||||
@ -80,62 +80,67 @@ export function useProjectionToggle(
|
||||
};
|
||||
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||
|
||||
const reorderRafRef = useRef(0);
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || projectionRef.current !== 'globe') return;
|
||||
if (!mapRef.current || projectionRef.current !== 'globe') return;
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행
|
||||
reorderRafRef.current = requestAnimationFrame(() => {
|
||||
reorderRafRef.current = 0;
|
||||
const m = mapRef.current;
|
||||
if (!m || !m.isStyleLoaded()) return;
|
||||
|
||||
const ordering = [
|
||||
'subcables-hitarea',
|
||||
'subcables-casing',
|
||||
'subcables-line',
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'track-replay-globe-path',
|
||||
'track-replay-globe-points',
|
||||
'track-replay-globe-virtual-ship',
|
||||
'track-replay-globe-virtual-label',
|
||||
'zones-fill',
|
||||
'zones-line',
|
||||
'zones-label',
|
||||
'predict-vectors-outline',
|
||||
'predict-vectors',
|
||||
'predict-vectors-hl-outline',
|
||||
'predict-vectors-hl',
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse',
|
||||
'ships-globe-lite',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-alarm-badge',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'pair-range-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
];
|
||||
const ordering = [
|
||||
'subcables-hitarea',
|
||||
'subcables-casing',
|
||||
'subcables-line',
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'track-replay-globe-path',
|
||||
'track-replay-globe-points',
|
||||
'track-replay-globe-virtual-ship',
|
||||
'track-replay-globe-virtual-label',
|
||||
'zones-fill',
|
||||
'zones-line',
|
||||
'zones-label',
|
||||
'fleet-circles-ml',
|
||||
'fc-lines-ml',
|
||||
'pair-range-ml',
|
||||
'pair-lines-ml',
|
||||
'predict-vectors-outline',
|
||||
'predict-vectors',
|
||||
'predict-vectors-hl-outline',
|
||||
'predict-vectors-hl',
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse',
|
||||
'ships-globe-lite',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-alarm-badge',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
];
|
||||
|
||||
for (const layerId of ordering) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
||||
} catch {
|
||||
// ignore
|
||||
for (const layerId of ordering) {
|
||||
try {
|
||||
if (m.getLayer(layerId)) m.moveLayer(layerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
kickRepaint(m);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Projection toggle (mercator <-> globe)
|
||||
|
||||
@ -13,10 +13,11 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
||||
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로
|
||||
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */
|
||||
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 ~33x로 폭증하므로
|
||||
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음.
|
||||
* 4수역 × 300pts × 33x ≈ 39,600 vertices (< 65535 limit). */
|
||||
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
||||
const MAX_PTS = 60;
|
||||
const MAX_PTS = 300;
|
||||
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
||||
if (ring.length <= MAX_PTS) return ring;
|
||||
const step = Math.ceil(ring.length / MAX_PTS);
|
||||
|
||||
@ -49,7 +49,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
||||
try {
|
||||
map.off('style.load', runOnce);
|
||||
map.off('styledata', runOnce);
|
||||
map.off('idle', runOnce);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -57,7 +56,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
||||
|
||||
map.on('style.load', runOnce);
|
||||
map.on('styledata', runOnce);
|
||||
map.on('idle', runOnce);
|
||||
|
||||
return () => {
|
||||
if (fired) return;
|
||||
@ -66,7 +64,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
||||
if (!map) return;
|
||||
map.off('style.load', runOnce);
|
||||
map.off('styledata', runOnce);
|
||||
map.off('idle', runOnce);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@ -41,28 +41,46 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
||||
return ['any', ...clauses] as unknown[];
|
||||
}
|
||||
|
||||
export function makeGlobeCircleRadiusExpr() {
|
||||
const base3 = 4;
|
||||
const base7 = 6;
|
||||
const base10 = 8;
|
||||
const base14 = 12;
|
||||
const base18 = 32;
|
||||
// ── Globe circle radius zoom stops ──
|
||||
// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용
|
||||
// → 하나의 interpolate 안에서 각 stop 값을 case로 분기
|
||||
const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const;
|
||||
const BASE_VALUES = [4, 6, 8, 12, 32] as const;
|
||||
const SELECTED_VALUES = [4.6, 6.8, 9.0, 13.5, 36] as const;
|
||||
const HIGHLIGHTED_VALUES = [4.2, 6.2, 8.2, 12.6, 34] as const;
|
||||
|
||||
return [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
3,
|
||||
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
|
||||
7,
|
||||
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
|
||||
10,
|
||||
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
|
||||
14,
|
||||
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
|
||||
18,
|
||||
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
|
||||
];
|
||||
function buildStopsWithCase(getter: (key: string) => unknown[]) {
|
||||
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||
stops.push(ZOOM_LEVELS[i]);
|
||||
stops.push([
|
||||
'case',
|
||||
['==', getter('selected'), 1], SELECTED_VALUES[i],
|
||||
['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i],
|
||||
BASE_VALUES[i],
|
||||
]);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */
|
||||
export function makeGlobeCircleRadiusExpr() {
|
||||
return buildStopsWithCase((key) => ['feature-state', key]);
|
||||
}
|
||||
|
||||
/** GeoJSON property 기반 — hover overlay 레이어 */
|
||||
export function makeGlobeCircleRadiusPropExpr() {
|
||||
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||
stops.push(ZOOM_LEVELS[i]);
|
||||
stops.push([
|
||||
'case',
|
||||
['==', ['get', 'selected'], 1], SELECTED_VALUES[i],
|
||||
HIGHLIGHTED_VALUES[i],
|
||||
]);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||
export const GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR = makeGlobeCircleRadiusPropExpr() as never;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user