Compare commits
3 커밋
918b80e06a
...
cd60f553ee
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| cd60f553ee | |||
| c5d89c5641 | |||
| 51090aca2a |
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"name": "CARTO Dark (Legacy)",
|
"name": "CARTO Dark (Legacy)",
|
||||||
|
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
"sources": {
|
"sources": {
|
||||||
"carto-dark": {
|
"carto-dark": {
|
||||||
"type": "raster",
|
"type": "raster",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"name": "OSM Raster + OpenSeaMap",
|
"name": "OSM Raster + OpenSeaMap",
|
||||||
|
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
"sources": {
|
"sources": {
|
||||||
"osm": {
|
"osm": {
|
||||||
"type": "raster",
|
"type": "raster",
|
||||||
|
|||||||
@ -115,8 +115,8 @@ export function DashboardPage() {
|
|||||||
fcLines: true,
|
fcLines: true,
|
||||||
zones: true,
|
zones: true,
|
||||||
fleetCircles: true,
|
fleetCircles: true,
|
||||||
predictVectors: false,
|
predictVectors: true,
|
||||||
shipLabels: false,
|
shipLabels: true,
|
||||||
});
|
});
|
||||||
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
||||||
|
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
166
apps/web/src/widgets/map3d/constants.ts
Normal file
166
apps/web/src/widgets/map3d/constants.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
LEGACY_CODE_COLORS_RGB,
|
||||||
|
OVERLAY_RGB,
|
||||||
|
rgba as rgbaCss,
|
||||||
|
} from '../../shared/lib/map/palette';
|
||||||
|
import type { BathyZoomRange } from './types';
|
||||||
|
|
||||||
|
// ── Re-export palette aliases used throughout Map3D ──
|
||||||
|
|
||||||
|
export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB;
|
||||||
|
|
||||||
|
const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal;
|
||||||
|
const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn;
|
||||||
|
const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
|
||||||
|
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||||
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
|
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||||
|
|
||||||
|
export const SHIP_ICON_MAPPING = {
|
||||||
|
ship: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
anchorX: 64,
|
||||||
|
anchorY: 64,
|
||||||
|
mask: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── Ship constants ──
|
||||||
|
|
||||||
|
export const ANCHOR_SPEED_THRESHOLD_KN = 1;
|
||||||
|
export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon';
|
||||||
|
|
||||||
|
// ── Geometry constants ──
|
||||||
|
|
||||||
|
export const DEG2RAD = Math.PI / 180;
|
||||||
|
export const RAD2DEG = 180 / Math.PI;
|
||||||
|
export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
|
||||||
|
export const GLOBE_ICON_HEADING_OFFSET_DEG = 0;
|
||||||
|
|
||||||
|
// ── Ship color constants ──
|
||||||
|
|
||||||
|
export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
|
||||||
|
export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11];
|
||||||
|
export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139];
|
||||||
|
|
||||||
|
// ── Ship outline / halo contrast colors ──
|
||||||
|
|
||||||
|
export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 240, 155];
|
||||||
|
export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230];
|
||||||
|
export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210];
|
||||||
|
export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)';
|
||||||
|
export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)';
|
||||||
|
|
||||||
|
// ── Flat map icon sizes ──
|
||||||
|
|
||||||
|
export const FLAT_SHIP_ICON_SIZE = 19;
|
||||||
|
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
|
||||||
|
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
|
||||||
|
export const EMPTY_MMSI_SET = new Set<number>();
|
||||||
|
|
||||||
|
// ── Deck.gl view ID ──
|
||||||
|
|
||||||
|
export const DECK_VIEW_ID = 'mapbox';
|
||||||
|
|
||||||
|
// ── Depth params ──
|
||||||
|
|
||||||
|
export const DEPTH_DISABLED_PARAMS = {
|
||||||
|
depthCompare: 'always',
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GLOBE_OVERLAY_PARAMS = {
|
||||||
|
depthCompare: 'less-equal',
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── Deck.gl color constants (avoid per-object allocations inside accessors) ──
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110,
|
||||||
|
];
|
||||||
|
export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220,
|
||||||
|
];
|
||||||
|
export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200,
|
||||||
|
];
|
||||||
|
export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220,
|
||||||
|
];
|
||||||
|
export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
|
||||||
|
];
|
||||||
|
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Highlighted variants ──
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200,
|
||||||
|
];
|
||||||
|
export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245,
|
||||||
|
];
|
||||||
|
export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235,
|
||||||
|
];
|
||||||
|
export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245,
|
||||||
|
];
|
||||||
|
export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
|
||||||
|
];
|
||||||
|
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── MapLibre overlay colors ──
|
||||||
|
|
||||||
|
export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55);
|
||||||
|
export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95);
|
||||||
|
export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95);
|
||||||
|
export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98);
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45);
|
||||||
|
export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75);
|
||||||
|
export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92);
|
||||||
|
export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92);
|
||||||
|
|
||||||
|
export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92);
|
||||||
|
export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
|
||||||
|
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
|
||||||
|
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
|
||||||
|
|
||||||
|
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
|
||||||
|
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
|
||||||
|
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
|
||||||
|
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
||||||
|
|
||||||
|
// ── Bathymetry zoom ranges ──
|
||||||
|
|
||||||
|
export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||||
|
{ id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] },
|
||||||
|
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] },
|
||||||
|
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] },
|
||||||
|
];
|
||||||
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore';
|
||||||
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
|
import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry';
|
||||||
|
|
||||||
|
export function useBaseMapToggle(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
opts: {
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
showSeamark: boolean;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
pulseMapSync: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts;
|
||||||
|
|
||||||
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
|
const bathyZoomProfileKeyRef = useRef<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showSeamarkRef.current = showSeamark;
|
||||||
|
}, [showSeamark]);
|
||||||
|
|
||||||
|
// Base map style toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let stop: (() => void) | null = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const style = await resolveMapStyle(baseMap, controller.signal);
|
||||||
|
if (cancelled) return;
|
||||||
|
map.setStyle(style, { diff: false });
|
||||||
|
stop = onMapStyleReady(map, () => {
|
||||||
|
kickRepaint(map);
|
||||||
|
requestAnimationFrame(() => kickRepaint(map));
|
||||||
|
pulseMapSync();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn('Base map switch failed:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
stop?.();
|
||||||
|
};
|
||||||
|
}, [baseMap]);
|
||||||
|
|
||||||
|
// Bathymetry zoom profile + water layer visibility
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const seaVisibility = 'visible' as const;
|
||||||
|
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
|
||||||
|
const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`;
|
||||||
|
if (bathyZoomProfileKeyRef.current !== nextProfileKey) {
|
||||||
|
applyBathymetryZoomProfile(map, baseMap, projection);
|
||||||
|
bathyZoomProfileKeyRef.current = nextProfileKey;
|
||||||
|
kickRepaint(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const style = map.getStyle();
|
||||||
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||||
|
for (const layer of styleLayers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (!id) continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '').toLowerCase();
|
||||||
|
const source = String((layer as { source?: unknown }).source ?? '').toLowerCase();
|
||||||
|
const type = String((layer as { type?: unknown }).type ?? '').toLowerCase();
|
||||||
|
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
|
||||||
|
const isRaster = type === 'raster';
|
||||||
|
if (!isSea) continue;
|
||||||
|
if (!map.getLayer(id)) continue;
|
||||||
|
if (isRaster && id === 'seamark') continue;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(id, 'visibility', seaVisibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, apply);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [projection, baseMap, mapSyncEpoch]);
|
||||||
|
|
||||||
|
// Seamark toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (showSeamark) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map, 'bathymetry-lines');
|
||||||
|
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||||
|
} catch {
|
||||||
|
// ignore until style is ready
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('seamark')) map.removeLayer('seamark');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getSource('seamark')) map.removeSource('seamark');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [showSeamark]);
|
||||||
|
}
|
||||||
662
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
662
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||||
|
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||||
|
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { type PickingInfo } from '@deck.gl/core';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import {
|
||||||
|
SHIP_ICON_MAPPING,
|
||||||
|
FLAT_SHIP_ICON_SIZE,
|
||||||
|
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||||
|
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||||
|
EMPTY_MMSI_SET,
|
||||||
|
DEPTH_DISABLED_PARAMS,
|
||||||
|
GLOBE_OVERLAY_PARAMS,
|
||||||
|
HALO_OUTLINE_COLOR,
|
||||||
|
HALO_OUTLINE_COLOR_SELECTED,
|
||||||
|
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||||
|
PAIR_RANGE_NORMAL_DECK,
|
||||||
|
PAIR_RANGE_WARN_DECK,
|
||||||
|
PAIR_LINE_NORMAL_DECK,
|
||||||
|
PAIR_LINE_WARN_DECK,
|
||||||
|
FC_LINE_NORMAL_DECK,
|
||||||
|
FC_LINE_SUSPICIOUS_DECK,
|
||||||
|
FLEET_RANGE_LINE_DECK,
|
||||||
|
FLEET_RANGE_FILL_DECK,
|
||||||
|
PAIR_RANGE_NORMAL_DECK_HL,
|
||||||
|
PAIR_RANGE_WARN_DECK_HL,
|
||||||
|
PAIR_LINE_NORMAL_DECK_HL,
|
||||||
|
PAIR_LINE_WARN_DECK_HL,
|
||||||
|
FC_LINE_NORMAL_DECK_HL,
|
||||||
|
FC_LINE_SUSPICIOUS_DECK_HL,
|
||||||
|
FLEET_RANGE_LINE_DECK_HL,
|
||||||
|
FLEET_RANGE_FILL_DECK_HL,
|
||||||
|
} from '../constants';
|
||||||
|
import { toSafeNumber } from '../lib/setUtils';
|
||||||
|
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
|
||||||
|
import {
|
||||||
|
getShipTooltipHtml,
|
||||||
|
getPairLinkTooltipHtml,
|
||||||
|
getFcLinkTooltipHtml,
|
||||||
|
getRangeTooltipHtml,
|
||||||
|
getFleetCircleTooltipHtml,
|
||||||
|
} from '../lib/tooltips';
|
||||||
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
|
|
||||||
|
export function useDeckLayers(
|
||||||
|
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
shipLayerData: AisTarget[];
|
||||||
|
shipOverlayLayerData: AisTarget[];
|
||||||
|
shipData: AisTarget[];
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
pairLinks: PairLink[] | undefined;
|
||||||
|
fcLinks: FcLink[] | undefined;
|
||||||
|
fcDashed: DashSeg[];
|
||||||
|
fleetCircles: FleetCircle[] | undefined;
|
||||||
|
pairRanges: PairRangeCircle[];
|
||||||
|
pairLinksInteractive: PairLink[];
|
||||||
|
pairRangesInteractive: PairRangeCircle[];
|
||||||
|
fcLinesInteractive: DashSeg[];
|
||||||
|
fleetCirclesInteractive: FleetCircle[];
|
||||||
|
overlays: MapToggleState;
|
||||||
|
shipByMmsi: Map<number, AisTarget>;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
shipHighlightSet: Set<number>;
|
||||||
|
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
||||||
|
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
||||||
|
isHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
|
clearDeckHoverPairs: () => void;
|
||||||
|
clearDeckHoverMmsi: () => void;
|
||||||
|
clearMapFleetHoverState: () => void;
|
||||||
|
setDeckHoverPairs: (next: number[]) => void;
|
||||||
|
setDeckHoverMmsi: (next: number[]) => void;
|
||||||
|
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||||
|
toFleetMmsiList: (value: unknown) => number[];
|
||||||
|
touchDeckHoverState: (isHover: boolean) => void;
|
||||||
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||||
|
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
|
isHighlightedFleet, isHighlightedPair, isHighlightedMmsi,
|
||||||
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
|
ensureMercatorOverlay, projectionRef,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const legacyTargets = useMemo(() => {
|
||||||
|
if (!legacyHits) return [];
|
||||||
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||||
|
}, [shipData, legacyHits]);
|
||||||
|
|
||||||
|
const legacyTargetsOrdered = useMemo(() => {
|
||||||
|
if (legacyTargets.length === 0) return legacyTargets;
|
||||||
|
const layer = [...legacyTargets];
|
||||||
|
layer.sort((a, b) => a.mmsi - b.mmsi);
|
||||||
|
return layer;
|
||||||
|
}, [legacyTargets]);
|
||||||
|
|
||||||
|
const legacyOverlayTargets = useMemo(() => {
|
||||||
|
if (shipHighlightSet.size === 0) return [];
|
||||||
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
|
}, [legacyTargets, shipHighlightSet]);
|
||||||
|
|
||||||
|
// Mercator Deck layers
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (projection !== 'mercator' || projectionBusyRef.current) {
|
||||||
|
if (projection !== 'mercator') {
|
||||||
|
try {
|
||||||
|
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckTarget = ensureMercatorOverlay();
|
||||||
|
if (!deckTarget) return;
|
||||||
|
|
||||||
|
const layers: unknown[] = [];
|
||||||
|
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||||
|
const clearDeckHover = () => {
|
||||||
|
touchDeckHoverState(false);
|
||||||
|
};
|
||||||
|
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
|
||||||
|
const shipOtherData: AisTarget[] = [];
|
||||||
|
const shipTargetData: AisTarget[] = [];
|
||||||
|
for (const t of shipLayerData) {
|
||||||
|
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||||
|
else shipOtherData.push(t);
|
||||||
|
}
|
||||||
|
const shipOverlayOtherData: AisTarget[] = [];
|
||||||
|
const shipOverlayTargetData: AisTarget[] = [];
|
||||||
|
for (const t of shipOverlayLayerData) {
|
||||||
|
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
|
||||||
|
else shipOverlayOtherData.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showDensity) {
|
||||||
|
layers.push(
|
||||||
|
new HexagonLayer<AisTarget>({
|
||||||
|
id: 'density',
|
||||||
|
data: shipLayerData,
|
||||||
|
pickable: true,
|
||||||
|
extruded: true,
|
||||||
|
radius: 2500,
|
||||||
|
elevationScale: 35,
|
||||||
|
coverage: 0.92,
|
||||||
|
opacity: 0.35,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRanges.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
|
id: 'pair-range',
|
||||||
|
data: pairRanges,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
radiusMinPixels: 10,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 1,
|
||||||
|
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const p = info.object as PairRangeCircle;
|
||||||
|
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||||
|
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
const obj = info.object as PairRangeCircle;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<PairLink>({
|
||||||
|
id: 'pair-lines',
|
||||||
|
data: pairLinks,
|
||||||
|
pickable: true,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||||
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||||
|
widthUnits: 'pixels',
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as PairLink;
|
||||||
|
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||||
|
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as PairLink;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fcLines && fcDashed.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<DashSeg>({
|
||||||
|
id: 'fc-lines',
|
||||||
|
data: fcDashed,
|
||||||
|
pickable: true,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||||
|
getWidth: () => 1.3,
|
||||||
|
widthUnits: 'pixels',
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as DashSeg;
|
||||||
|
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||||
|
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||||
|
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as DashSeg;
|
||||||
|
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.toMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<FleetCircle>({
|
||||||
|
id: 'fleet-circles',
|
||||||
|
data: fleetCircles,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 1.1,
|
||||||
|
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as FleetCircle;
|
||||||
|
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||||
|
setMapFleetHoverState(obj.ownerKey || null, list);
|
||||||
|
setDeckHoverMmsi(list);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as FleetCircle;
|
||||||
|
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = list[0];
|
||||||
|
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<FleetCircle>({
|
||||||
|
id: 'fleet-circles-fill',
|
||||||
|
data: fleetCircles,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips) {
|
||||||
|
const shipOnHover = (info: PickingInfo) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as AisTarget;
|
||||||
|
setDeckHoverMmsi([obj.mmsi]);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
const shipOnClick = (info: PickingInfo) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
onDeckSelectOrHighlight(
|
||||||
|
{
|
||||||
|
mmsi: (info.object as AisTarget).mmsi,
|
||||||
|
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shipOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-other',
|
||||||
|
data: shipOtherData,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
iconAtlas: '/assets/ship.svg',
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||||
|
onHover: shipOnHover,
|
||||||
|
onClick: shipOnClick,
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shipOverlayOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-overlay-other',
|
||||||
|
data: shipOverlayOtherData,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
iconAtlas: '/assets/ship.svg',
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: (d) => {
|
||||||
|
if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||||
|
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyTargetsOrdered.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'legacy-halo',
|
||||||
|
data: legacyTargetsOrdered,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 2,
|
||||||
|
getLineColor: () => HALO_OUTLINE_COLOR,
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shipTargetData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-target',
|
||||||
|
data: shipTargetData,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
iconAtlas: '/assets/ship.svg',
|
||||||
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
|
getIcon: () => 'ship',
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||||
|
onHover: shipOnHover,
|
||||||
|
onClick: shipOnClick,
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRangesInteractive.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||||
|
}
|
||||||
|
if (overlays.pairLines && pairLinksInteractive.length > 0) {
|
||||||
|
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
||||||
|
}
|
||||||
|
if (overlays.fcLines && fcLinesInteractive.length > 0) {
|
||||||
|
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
||||||
|
}
|
||||||
|
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||||
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && legacyOverlayTargets.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||||
|
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
|
||||||
|
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||||
|
const deckProps = {
|
||||||
|
layers: normalizedLayers,
|
||||||
|
getTooltip: (info: PickingInfo) => {
|
||||||
|
if (!info.object) return null;
|
||||||
|
if (info.layer && info.layer.id === 'density') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const o: any = info.object;
|
||||||
|
const n = Array.isArray(o?.points) ? o.points.length : 0;
|
||||||
|
return { text: `AIS density: ${n}` };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === 'number') {
|
||||||
|
return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'pair-lines') {
|
||||||
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||||
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'fc-lines') {
|
||||||
|
const fcMmsi = toSafeNumber(obj.fcMmsi);
|
||||||
|
const otherMmsi = toSafeNumber(obj.otherMmsi);
|
||||||
|
if (fcMmsi == null || otherMmsi == null) return null;
|
||||||
|
return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'pair-range') {
|
||||||
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||||
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'fleet-circles') {
|
||||||
|
return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onClick: (info: PickingInfo) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
if (info.layer && info.layer.id === 'density') return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === 'number') {
|
||||||
|
const t = obj as AisTarget;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(t.mmsi);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
deckTarget.setProps(deckProps as never);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
|
||||||
|
try {
|
||||||
|
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
|
||||||
|
} catch {
|
||||||
|
// Ignore secondary failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
ensureMercatorOverlay,
|
||||||
|
projection,
|
||||||
|
shipLayerData,
|
||||||
|
shipByMmsi,
|
||||||
|
pairRanges,
|
||||||
|
pairLinks,
|
||||||
|
fcDashed,
|
||||||
|
fleetCircles,
|
||||||
|
legacyTargetsOrdered,
|
||||||
|
legacyHits,
|
||||||
|
legacyOverlayTargets,
|
||||||
|
shipOverlayLayerData,
|
||||||
|
pairRangesInteractive,
|
||||||
|
pairLinksInteractive,
|
||||||
|
fcLinesInteractive,
|
||||||
|
fleetCirclesInteractive,
|
||||||
|
overlays.pairRange,
|
||||||
|
overlays.pairLines,
|
||||||
|
overlays.fcLines,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
settings.showDensity,
|
||||||
|
settings.showShips,
|
||||||
|
onDeckSelectOrHighlight,
|
||||||
|
onSelectMmsi,
|
||||||
|
onToggleHighlightMmsi,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
toFleetMmsiList,
|
||||||
|
touchDeckHoverState,
|
||||||
|
hasAuxiliarySelectModifier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Globe Deck overlay
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||||
|
const deckTarget = globeDeckLayerRef.current;
|
||||||
|
if (!deckTarget) return;
|
||||||
|
|
||||||
|
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||||
|
const globeLayers: unknown[] = [];
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRanges.length > 0) {
|
||||||
|
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||||
|
const links = pairLinks || [];
|
||||||
|
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fcLines && fcDashed.length > 0) {
|
||||||
|
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||||
|
const circles = fleetCircles || [];
|
||||||
|
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
|
||||||
|
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && legacyTargetsOrdered.length > 0) {
|
||||||
|
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||||
|
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||||
|
|
||||||
|
try {
|
||||||
|
deckTarget.setProps(globeDeckProps as never);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e);
|
||||||
|
try {
|
||||||
|
deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never);
|
||||||
|
} catch {
|
||||||
|
// Ignore secondary failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
pairRanges,
|
||||||
|
pairLinks,
|
||||||
|
fcDashed,
|
||||||
|
fleetCircles,
|
||||||
|
legacyTargetsOrdered,
|
||||||
|
overlays.pairRange,
|
||||||
|
overlays.pairLines,
|
||||||
|
overlays.fcLines,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
settings.showShips,
|
||||||
|
selectedMmsi,
|
||||||
|
isHighlightedFleet,
|
||||||
|
isHighlightedPair,
|
||||||
|
clearDeckHoverPairs,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
setMapFleetHoverState,
|
||||||
|
toFleetMmsiList,
|
||||||
|
touchDeckHoverState,
|
||||||
|
legacyHits,
|
||||||
|
]);
|
||||||
|
}
|
||||||
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import { onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
|
export function useFlyTo(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>,
|
||||||
|
opts: {
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
shipData: { mmsi: number; lon: number; lat: number }[];
|
||||||
|
fleetFocusId: string | number | undefined;
|
||||||
|
fleetFocusLon: number | undefined;
|
||||||
|
fleetFocusLat: number | undefined;
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||||
|
return;
|
||||||
|
const lon = fleetFocusLon;
|
||||||
|
const lat = fleetFocusLat;
|
||||||
|
const zoom = fleetFocusZoom ?? 10;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
||||||
|
if (projectionRef.current === 'globe') {
|
||||||
|
map.flyTo(flyOpts);
|
||||||
|
} else {
|
||||||
|
map.easeTo(flyOpts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, apply);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||||
|
}
|
||||||
318
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
318
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import { toIntMmsi, toSafeNumber } from '../lib/setUtils';
|
||||||
|
import {
|
||||||
|
getShipTooltipHtml,
|
||||||
|
getPairLinkTooltipHtml,
|
||||||
|
getFcLinkTooltipHtml,
|
||||||
|
getRangeTooltipHtml,
|
||||||
|
getFleetCircleTooltipHtml,
|
||||||
|
} from '../lib/tooltips';
|
||||||
|
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||||
|
|
||||||
|
export function useGlobeInteraction(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
targets: AisTarget[];
|
||||||
|
shipData: AisTarget[];
|
||||||
|
shipByMmsi: Map<number, AisTarget>;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
hoveredZoneId: string | null;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
clearDeckHoverPairs: () => void;
|
||||||
|
clearDeckHoverMmsi: () => void;
|
||||||
|
clearMapFleetHoverState: () => void;
|
||||||
|
setDeckHoverPairs: (next: number[]) => void;
|
||||||
|
setDeckHoverMmsi: (next: number[]) => void;
|
||||||
|
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||||
|
setHoveredZoneId: (updater: (prev: string | null) => string | null) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, legacyHits, shipByMmsi,
|
||||||
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
|
||||||
|
const clearGlobeTooltip = useCallback(() => {
|
||||||
|
if (!mapTooltipRef.current) return;
|
||||||
|
try {
|
||||||
|
mapTooltipRef.current.remove();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
mapTooltipRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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 (!mapTooltipRef.current) {
|
||||||
|
mapTooltipRef.current = new maplibregl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
maxWidth: '360px',
|
||||||
|
className: 'maplibre-tooltip-popup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'maplibre-tooltip-popup__content';
|
||||||
|
container.innerHTML = tooltipHtml;
|
||||||
|
|
||||||
|
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildGlobeFeatureTooltip = useCallback(
|
||||||
|
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||||
|
if (!feature) return null;
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const layerId = feature.layer?.id;
|
||||||
|
|
||||||
|
const maybeMmsi = toIntMmsi(props.mmsi);
|
||||||
|
if (maybeMmsi != null && maybeMmsi > 0) {
|
||||||
|
return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'pair-lines-ml') {
|
||||||
|
const warn = props.warn === true;
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getPairLinkTooltipHtml({
|
||||||
|
warn, distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'fc-lines-ml') {
|
||||||
|
const fcMmsi = toIntMmsi(props.fcMmsi);
|
||||||
|
const otherMmsi = toIntMmsi(props.otherMmsi);
|
||||||
|
if (fcMmsi == null || otherMmsi == null) return null;
|
||||||
|
return getFcLinkTooltipHtml({
|
||||||
|
suspicious: props.suspicious === true,
|
||||||
|
distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'pair-range-ml') {
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getRangeTooltipHtml({
|
||||||
|
warn: props.warn === true,
|
||||||
|
distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
aMmsi, bMmsi, legacyHits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
|
||||||
|
return getFleetCircleTooltipHtml({
|
||||||
|
ownerKey: String(props.ownerKey ?? ''),
|
||||||
|
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||||
|
count: Number(props.count ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneLabel = getZoneDisplayNameFromProps(props);
|
||||||
|
if (zoneLabel) {
|
||||||
|
return { html: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[legacyHits, shipByMmsi],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const clearDeckGlobeHoverState = () => {
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGlobeHoverStates = () => {
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMmsiList = (value: unknown): number[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const n of value) {
|
||||||
|
const m = toIntMmsi(n);
|
||||||
|
if (m != null) out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (projection !== 'globe') {
|
||||||
|
clearGlobeTooltip();
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
clearDeckGlobeHoverState();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateLayerIds: string[] = [];
|
||||||
|
try {
|
||||||
|
candidateLayerIds = [
|
||||||
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
|
'fleet-circles-ml', 'fleet-circles-ml-fill',
|
||||||
|
'pair-range-ml',
|
||||||
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
|
].filter((id) => map.getLayer(id));
|
||||||
|
} catch {
|
||||||
|
candidateLayerIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateLayerIds.length === 0) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||||
|
try {
|
||||||
|
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
||||||
|
properties?: Record<string, unknown> | null;
|
||||||
|
layer?: { id?: string };
|
||||||
|
}>;
|
||||||
|
} catch {
|
||||||
|
rendered = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = [
|
||||||
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||||
|
'fleet-circles-ml-fill', 'fleet-circles-ml',
|
||||||
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
|
];
|
||||||
|
|
||||||
|
const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as
|
||||||
|
| { properties?: Record<string, unknown> | null; layer?: { id?: string } }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerId = first.layer?.id;
|
||||||
|
const props = first.properties || {};
|
||||||
|
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||||
|
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||||
|
const isFcLayer = layerId === 'fc-lines-ml';
|
||||||
|
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
|
||||||
|
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||||
|
|
||||||
|
if (isShipLayer) {
|
||||||
|
const mmsi = toIntMmsi(props.mmsi);
|
||||||
|
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isPairLayer) {
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isFcLayer) {
|
||||||
|
const from = toIntMmsi(props.fcMmsi);
|
||||||
|
const to = toIntMmsi(props.otherMmsi);
|
||||||
|
const fromTo = [from, to].filter((v): v is number => v != null);
|
||||||
|
setDeckHoverPairs(fromTo);
|
||||||
|
setDeckHoverMmsi(fromTo);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isFleetLayer) {
|
||||||
|
const ownerKey = String(props.ownerKey ?? '');
|
||||||
|
const list = normalizeMmsiList(props.vesselMmsis);
|
||||||
|
setMapFleetHoverState(ownerKey || null, list);
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isZoneLayer) {
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
const zoneId = getZoneIdFromProps(props);
|
||||||
|
setHoveredZoneId(() => zoneId || null);
|
||||||
|
} else {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = buildGlobeFeatureTooltip(first);
|
||||||
|
if (!tooltip) {
|
||||||
|
if (!isZoneLayer) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
}
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = tooltip?.html ?? '';
|
||||||
|
if (content) {
|
||||||
|
setGlobeTooltip(e.lngLat, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseOut = () => {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('mousemove', onMouseMove);
|
||||||
|
map.on('mouseout', onMouseOut);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('mousemove', onMouseMove);
|
||||||
|
map.off('mouseout', onMouseOut);
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
buildGlobeFeatureTooltip,
|
||||||
|
clearGlobeTooltip,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
clearDeckHoverPairs,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
setMapFleetHoverState,
|
||||||
|
setGlobeTooltip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
618
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
618
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
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_FILL_ML, 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 { 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 = () => {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fillSrcId = 'fleet-circles-ml-fill-src';
|
||||||
|
const layerId = 'fleet-circles-ml';
|
||||||
|
const fillLayerId = 'fleet-circles-ml-fill';
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: (fleetCircles || []).map((c) => {
|
||||||
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
|
||||||
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||||
|
properties: {
|
||||||
|
type: 'fleet-fill',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(fillLayerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: fillLayerId,
|
||||||
|
type: 'fill',
|
||||||
|
source: fillSrcId,
|
||||||
|
layout: { visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
|
||||||
|
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fleet circles fill layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (map.getLayer('fleet-circles-ml-fill')) {
|
||||||
|
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
|
||||||
|
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
1038
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
1038
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toNumberSet } from '../lib/setUtils';
|
||||||
|
|
||||||
|
export interface HoverStateInput {
|
||||||
|
hoveredMmsiSet: number[];
|
||||||
|
hoveredFleetMmsiSet: number[];
|
||||||
|
hoveredPairMmsiSet: number[];
|
||||||
|
hoveredFleetOwnerKey: string | null;
|
||||||
|
highlightedMmsiSet: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHoverState(input: HoverStateInput) {
|
||||||
|
const {
|
||||||
|
hoveredMmsiSet,
|
||||||
|
hoveredFleetMmsiSet,
|
||||||
|
hoveredPairMmsiSet,
|
||||||
|
hoveredFleetOwnerKey,
|
||||||
|
highlightedMmsiSet,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
// Internal deck hover states
|
||||||
|
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
|
||||||
|
const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredZoneId, setHoveredZoneId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Derived sets
|
||||||
|
const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]);
|
||||||
|
const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]);
|
||||||
|
const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]);
|
||||||
|
const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]);
|
||||||
|
const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]);
|
||||||
|
const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]);
|
||||||
|
const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]);
|
||||||
|
|
||||||
|
const hoveredFleetOwnerKeys = useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey);
|
||||||
|
if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey);
|
||||||
|
return keys;
|
||||||
|
}, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Internal states + setters
|
||||||
|
hoveredDeckMmsiSet,
|
||||||
|
setHoveredDeckMmsiSet,
|
||||||
|
hoveredDeckPairMmsiSet,
|
||||||
|
setHoveredDeckPairMmsiSet,
|
||||||
|
hoveredDeckFleetOwnerKey,
|
||||||
|
setHoveredDeckFleetOwnerKey,
|
||||||
|
hoveredDeckFleetMmsiSet,
|
||||||
|
setHoveredDeckFleetMmsiSet,
|
||||||
|
hoveredZoneId,
|
||||||
|
setHoveredZoneId,
|
||||||
|
// Derived sets
|
||||||
|
hoveredMmsiSetRef,
|
||||||
|
hoveredFleetMmsiSetRef,
|
||||||
|
hoveredPairMmsiSetRef,
|
||||||
|
externalHighlightedSetRef,
|
||||||
|
hoveredDeckMmsiSetRef,
|
||||||
|
hoveredDeckPairMmsiSetRef,
|
||||||
|
hoveredDeckFleetMmsiSetRef,
|
||||||
|
hoveredFleetOwnerKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
196
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
196
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
|
import { DECK_VIEW_ID } from '../constants';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
|
import { resolveMapStyle } from '../layers/bathymetry';
|
||||||
|
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
|
||||||
|
|
||||||
|
export function useMapInit(
|
||||||
|
containerRef: MutableRefObject<HTMLDivElement | null>,
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
baseMapRef: MutableRefObject<BaseMapId>,
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>,
|
||||||
|
opts: {
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
showSeamark: boolean;
|
||||||
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
|
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
||||||
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
|
useEffect(() => {
|
||||||
|
showSeamarkRef.current = showSeamark;
|
||||||
|
}, [showSeamark]);
|
||||||
|
|
||||||
|
const ensureMercatorOverlay = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return null;
|
||||||
|
if (overlayRef.current) return overlayRef.current;
|
||||||
|
try {
|
||||||
|
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
|
||||||
|
map.addControl(next);
|
||||||
|
overlayRef.current = next;
|
||||||
|
return next;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Deck overlay create failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearGlobeNativeLayersCb = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
clearGlobeNativeLayers(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pulseMapSync = useCallback(() => {
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
kickRepaint(mapRef.current);
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
}, [setMapSyncEpoch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
let map: maplibregl.Map | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let style: string | StyleSpecification = '/map/styles/osm-seamark.json';
|
||||||
|
try {
|
||||||
|
style = await resolveMapStyle(baseMapRef.current, controller.signal);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Map style init failed, falling back to local raster style:', e);
|
||||||
|
style = '/map/styles/osm-seamark.json';
|
||||||
|
}
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style,
|
||||||
|
center: [126.5, 34.2],
|
||||||
|
zoom: 7,
|
||||||
|
pitch: 45,
|
||||||
|
bearing: 0,
|
||||||
|
maxPitch: 85,
|
||||||
|
dragRotate: true,
|
||||||
|
pitchWithRotate: true,
|
||||||
|
touchPitch: true,
|
||||||
|
scrollZoom: { around: 'center' },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
||||||
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
if (projectionRef.current === 'mercator') {
|
||||||
|
const overlay = ensureMercatorOverlay();
|
||||||
|
if (!overlay) return;
|
||||||
|
overlayRef.current = overlay;
|
||||||
|
} else {
|
||||||
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||||
|
id: 'deck-globe',
|
||||||
|
viewId: DECK_VIEW_ID,
|
||||||
|
deckProps: { layers: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProjection() {
|
||||||
|
if (!map) return;
|
||||||
|
const next = projectionRef.current;
|
||||||
|
if (next === 'mercator') return;
|
||||||
|
try {
|
||||||
|
map.setProjection({ type: next });
|
||||||
|
map.setRenderWorldCopies(next !== 'globe');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Projection apply failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMapStyleReady(map, () => {
|
||||||
|
applyProjection();
|
||||||
|
const deckLayer = globeDeckLayerRef.current;
|
||||||
|
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||||
|
try {
|
||||||
|
map!.addLayer(deckLayer);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showSeamarkRef.current) return;
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitBbox = () => {
|
||||||
|
const cb = onViewBboxChange;
|
||||||
|
if (!cb || !map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||||
|
};
|
||||||
|
map.on('load', emitBbox);
|
||||||
|
map.on('moveend', emitBbox);
|
||||||
|
|
||||||
|
map.once('load', () => {
|
||||||
|
if (showSeamarkRef.current) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const opacity = showSeamarkRef.current ? 0.85 : 0;
|
||||||
|
map!.setPaintProperty('seamark', 'raster-opacity', opacity);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
globeDeckLayerRef.current?.requestFinalize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove();
|
||||||
|
map = null;
|
||||||
|
}
|
||||||
|
if (overlayRef.current) {
|
||||||
|
overlayRef.current.finalize();
|
||||||
|
overlayRef.current = null;
|
||||||
|
}
|
||||||
|
if (overlayInteractionRef.current) {
|
||||||
|
overlayInteractionRef.current.finalize();
|
||||||
|
overlayInteractionRef.current = null;
|
||||||
|
}
|
||||||
|
globeDeckLayerRef.current = null;
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync };
|
||||||
|
}
|
||||||
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import { destinationPointLngLat } from '../lib/geometry';
|
||||||
|
import { isFiniteNumber } from '../lib/setUtils';
|
||||||
|
import { toValidBearingDeg, lightenColor } from '../lib/shipUtils';
|
||||||
|
|
||||||
|
export function usePredictionVectors(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
overlays: MapToggleState;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
shipData: AisTarget[];
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
externalHighlightedSetRef: Set<number>;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
baseMap: string;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'predict-vectors-src';
|
||||||
|
const outlineId = 'predict-vectors-outline';
|
||||||
|
const lineId = 'predict-vectors';
|
||||||
|
const hlOutlineId = 'predict-vectors-hl-outline';
|
||||||
|
const hlId = 'predict-vectors-hl';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const visibility = overlays.predictVectors ? 'visible' : 'none';
|
||||||
|
|
||||||
|
const horizonMinutes = 15;
|
||||||
|
const horizonSeconds = horizonMinutes * 60;
|
||||||
|
const metersPerSecondPerKnot = 0.514444;
|
||||||
|
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
||||||
|
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
|
||||||
|
for (const t of shipData) {
|
||||||
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
|
const isTarget = !!legacy;
|
||||||
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||||
|
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
|
||||||
|
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||||
|
|
||||||
|
const sog = isFiniteNumber(t.sog) ? t.sog : null;
|
||||||
|
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
|
||||||
|
if (sog == null || bearing == null) continue;
|
||||||
|
if (sog < 0.2) continue;
|
||||||
|
|
||||||
|
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
|
||||||
|
if (!Number.isFinite(distM) || distM <= 0) continue;
|
||||||
|
|
||||||
|
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
|
||||||
|
|
||||||
|
const baseRgb = isTarget
|
||||||
|
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving
|
||||||
|
: OTHER_AIS_SPEED_RGB.moving;
|
||||||
|
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
|
||||||
|
const alpha = isTarget ? 0.72 : 0.52;
|
||||||
|
const alphaHl = isTarget ? 0.92 : 0.84;
|
||||||
|
const hl = isSelected || isPinnedHighlight ? 1 : 0;
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `pred-${t.mmsi}`,
|
||||||
|
geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] },
|
||||||
|
properties: {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
minutes: horizonMinutes,
|
||||||
|
sog,
|
||||||
|
cog: bearing,
|
||||||
|
target: isTarget ? 1 : 0,
|
||||||
|
hl,
|
||||||
|
color: rgbaCss(rgb, alpha),
|
||||||
|
colorHl: rgbaCss(rgb, alphaHl),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { type: 'FeatureCollection', features };
|
||||||
|
|
||||||
|
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('Prediction vector source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => {
|
||||||
|
if (!map.getLayer(id)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
filter: filter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'line-cap': 'round',
|
||||||
|
'line-join': 'round',
|
||||||
|
},
|
||||||
|
paint,
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Prediction vector layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(id, 'visibility', visibility);
|
||||||
|
map.setFilter(id, filter as never);
|
||||||
|
if (paint && typeof paint === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(paint)) {
|
||||||
|
map.setPaintProperty(id, key as never, value as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[];
|
||||||
|
const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[];
|
||||||
|
|
||||||
|
ensureLayer(
|
||||||
|
outlineId,
|
||||||
|
{
|
||||||
|
'line-color': 'rgba(2,6,23,0.86)',
|
||||||
|
'line-width': 4.8,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-blur': 0.2,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
baseFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
lineId,
|
||||||
|
{
|
||||||
|
'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never,
|
||||||
|
'line-width': 2.4,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
baseFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
hlOutlineId,
|
||||||
|
{
|
||||||
|
'line-color': 'rgba(2,6,23,0.92)',
|
||||||
|
'line-width': 6.4,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-blur': 0.25,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
hlFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
hlId,
|
||||||
|
{
|
||||||
|
'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never,
|
||||||
|
'line-width': 3.6,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
hlFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
overlays.predictVectors,
|
||||||
|
settings.showShips,
|
||||||
|
shipData,
|
||||||
|
legacyHits,
|
||||||
|
selectedMmsi,
|
||||||
|
externalHighlightedSetRef,
|
||||||
|
projection,
|
||||||
|
baseMap,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
324
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
324
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
import { DECK_VIEW_ID } from '../constants';
|
||||||
|
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
||||||
|
import { removeLayerIfExists } from '../lib/layerHelpers';
|
||||||
|
|
||||||
|
export function useProjectionToggle(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
clearGlobeNativeLayers: () => void;
|
||||||
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||||
|
pulseMapSync: () => void;
|
||||||
|
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
const projectionBusyTokenRef = useRef(0);
|
||||||
|
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||||
|
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
||||||
|
const projectionRef = useRef<MapProjectionId>(projection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectionRef.current = projection;
|
||||||
|
}, [projection]);
|
||||||
|
|
||||||
|
const clearProjectionBusyTimer = useCallback(() => {
|
||||||
|
if (projectionBusyTimerRef.current == null) return;
|
||||||
|
clearTimeout(projectionBusyTimerRef.current);
|
||||||
|
projectionBusyTimerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const endProjectionLoading = useCallback(() => {
|
||||||
|
if (!projectionBusyRef.current) return;
|
||||||
|
projectionBusyRef.current = false;
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
if (onProjectionLoadingChange) {
|
||||||
|
onProjectionLoadingChange(false);
|
||||||
|
}
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
kickRepaint(mapRef.current);
|
||||||
|
}, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]);
|
||||||
|
|
||||||
|
const setProjectionLoading = useCallback(
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
(loading: boolean) => {
|
||||||
|
if (projectionBusyRef.current === loading) return;
|
||||||
|
if (!loading) {
|
||||||
|
endProjectionLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
projectionBusyRef.current = true;
|
||||||
|
const token = ++projectionBusyTokenRef.current;
|
||||||
|
if (onProjectionLoadingChange) {
|
||||||
|
onProjectionLoadingChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
projectionBusyTimerRef.current = setTimeout(() => {
|
||||||
|
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
||||||
|
console.debug('Projection loading fallback timeout reached.');
|
||||||
|
endProjectionLoading();
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
endProjectionLoading();
|
||||||
|
};
|
||||||
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projectionRef.current !== 'globe') return;
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const ordering = [
|
||||||
|
'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',
|
||||||
|
'ships-globe-label',
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const layerId of ordering) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Projection toggle (mercator <-> globe)
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let retries = 0;
|
||||||
|
const maxRetries = 18;
|
||||||
|
const isTransition = projectionPrevRef.current !== projection;
|
||||||
|
projectionPrevRef.current = projection;
|
||||||
|
let settleScheduled = false;
|
||||||
|
let settleCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const startProjectionSettle = () => {
|
||||||
|
if (!isTransition || settleScheduled) return;
|
||||||
|
settleScheduled = true;
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (!cancelled && isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeSoon = () => {
|
||||||
|
if (cancelled || !isTransition || projectionBusyRef.current === false) return;
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
requestAnimationFrame(finalizeSoon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(finalize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onIdle = () => finalizeSoon();
|
||||||
|
try {
|
||||||
|
map.on('idle', onIdle);
|
||||||
|
const styleReadyCleanup = onMapStyleReady(map, finalizeSoon);
|
||||||
|
settleCleanup = () => {
|
||||||
|
map.off('idle', onIdle);
|
||||||
|
styleReadyCleanup();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
requestAnimationFrame(finalize);
|
||||||
|
settleCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeSoon();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTransition) setProjectionLoading(true);
|
||||||
|
|
||||||
|
const disposeMercatorOverlays = () => {
|
||||||
|
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
target.setProps({ layers: [] } as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.removeControl(target as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.finalize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (toNull === 'base') {
|
||||||
|
overlayRef.current = null;
|
||||||
|
} else {
|
||||||
|
overlayInteractionRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
disposeOne(overlayRef.current, 'base');
|
||||||
|
disposeOne(overlayInteractionRef.current, 'interaction');
|
||||||
|
};
|
||||||
|
|
||||||
|
const disposeGlobeDeckLayer = () => {
|
||||||
|
const current = globeDeckLayerRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
removeLayerIfExists(map, current.id);
|
||||||
|
try {
|
||||||
|
current.requestFinalize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
globeDeckLayerRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncProjectionAndDeck = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!isTransition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
if (!cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = projection;
|
||||||
|
const currentProjection = extractProjectionType(map);
|
||||||
|
const shouldSwitchProjection = currentProjection !== next;
|
||||||
|
|
||||||
|
if (projection === 'globe') {
|
||||||
|
disposeMercatorOverlays();
|
||||||
|
clearGlobeNativeLayers();
|
||||||
|
} else {
|
||||||
|
disposeGlobeDeckLayer();
|
||||||
|
clearGlobeNativeLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldSwitchProjection) {
|
||||||
|
map.setProjection({ type: next });
|
||||||
|
}
|
||||||
|
map.setRenderWorldCopies(next !== 'globe');
|
||||||
|
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
console.warn('Projection switch failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projection === 'globe') {
|
||||||
|
disposeGlobeDeckLayer();
|
||||||
|
|
||||||
|
if (!globeDeckLayerRef.current) {
|
||||||
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||||
|
id: 'deck-globe',
|
||||||
|
viewId: DECK_VIEW_ID,
|
||||||
|
deckProps: { layers: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = globeDeckLayerRef.current;
|
||||||
|
const layerId = layer?.id;
|
||||||
|
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(layer);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disposeGlobeDeckLayer();
|
||||||
|
ensureMercatorOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
try {
|
||||||
|
map.resize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (isTransition) {
|
||||||
|
startProjectionSettle();
|
||||||
|
}
|
||||||
|
pulseMapSync();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isTransition) return;
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||||
|
else {
|
||||||
|
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (settleCleanup) settleCleanup();
|
||||||
|
stop();
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (settleCleanup) settleCleanup();
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||||
|
|
||||||
|
return reorderGlobeFeatureLayers;
|
||||||
|
}
|
||||||
230
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
230
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
|
type LayerSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||||
|
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||||
|
import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
|
export function useZonesLayer(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
hoveredZoneId: string | null;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'zones-src';
|
||||||
|
const fillId = 'zones-fill';
|
||||||
|
const lineId = 'zones-line';
|
||||||
|
const labelId = 'zones-label';
|
||||||
|
|
||||||
|
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
|
||||||
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||||
|
zoneColorExpr.push(k, ZONE_META[k].color);
|
||||||
|
}
|
||||||
|
zoneColorExpr.push('#3B82F6');
|
||||||
|
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
|
||||||
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||||
|
zoneLabelExpr.push(k, ZONE_META[k].name);
|
||||||
|
}
|
||||||
|
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
const visibility = overlays.zones ? 'visible' : 'none';
|
||||||
|
try {
|
||||||
|
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zones) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) {
|
||||||
|
existing.setData(zones);
|
||||||
|
} else {
|
||||||
|
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = map.getStyle();
|
||||||
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||||
|
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
|
||||||
|
| { id?: string }
|
||||||
|
| undefined;
|
||||||
|
const before = map.getLayer('deck-globe')
|
||||||
|
? 'deck-globe'
|
||||||
|
: map.getLayer('ships')
|
||||||
|
? 'ships'
|
||||||
|
: map.getLayer('seamark')
|
||||||
|
? 'seamark'
|
||||||
|
: firstSymbol?.id;
|
||||||
|
|
||||||
|
const zoneMatchExpr =
|
||||||
|
hoveredZoneId !== null
|
||||||
|
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
|
||||||
|
: false;
|
||||||
|
const zoneLineWidthExpr = hoveredZoneId
|
||||||
|
? ([
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['zoom'],
|
||||||
|
4,
|
||||||
|
['case', zoneMatchExpr, 1.6, 0.8],
|
||||||
|
10,
|
||||||
|
['case', zoneMatchExpr, 2.0, 1.4],
|
||||||
|
14,
|
||||||
|
['case', zoneMatchExpr, 2.8, 2.1],
|
||||||
|
] as unknown as never)
|
||||||
|
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
|
||||||
|
|
||||||
|
if (map.getLayer(fillId)) {
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(
|
||||||
|
fillId,
|
||||||
|
'fill-opacity',
|
||||||
|
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getLayer(lineId)) {
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(
|
||||||
|
lineId,
|
||||||
|
'line-color',
|
||||||
|
hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||||
|
: (zoneColorExpr as never),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(fillId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: fillId,
|
||||||
|
type: 'fill',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'fill-color': zoneColorExpr as never,
|
||||||
|
'fill-opacity': hoveredZoneId
|
||||||
|
? ([
|
||||||
|
'case',
|
||||||
|
zoneMatchExpr,
|
||||||
|
0.24,
|
||||||
|
0.1,
|
||||||
|
] as unknown as number)
|
||||||
|
: 0.12,
|
||||||
|
},
|
||||||
|
layout: { visibility },
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(lineId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: lineId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'line-color': hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||||
|
: (zoneColorExpr as never),
|
||||||
|
'line-opacity': hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 1, 0.85] as never)
|
||||||
|
: 0.85,
|
||||||
|
'line-width': zoneLineWidthExpr,
|
||||||
|
},
|
||||||
|
layout: { visibility },
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(labelId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: labelId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'symbol-placement': 'point',
|
||||||
|
'text-field': zoneLabelExpr as never,
|
||||||
|
'text-size': 11,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-offset': [0, 0.35],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-ignore-placement': false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#dbeafe',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.8,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Zones layer setup failed:', e);
|
||||||
|
} finally {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
}
|
||||||
292
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
292
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import maplibregl, {
|
||||||
|
type LayerSpecification,
|
||||||
|
type StyleSpecification,
|
||||||
|
type VectorSourceSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
|
||||||
|
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
|
||||||
|
|
||||||
|
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||||
|
{ id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] },
|
||||||
|
{ id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] },
|
||||||
|
{ id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||||
|
const oceanSourceId = 'maptiler-ocean';
|
||||||
|
|
||||||
|
if (!style.sources) style.sources = {} as StyleSpecification['sources'];
|
||||||
|
if (!style.layers) style.layers = [];
|
||||||
|
|
||||||
|
if (!style.sources[oceanSourceId]) {
|
||||||
|
style.sources[oceanSourceId] = {
|
||||||
|
type: 'vector',
|
||||||
|
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
||||||
|
} satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string];
|
||||||
|
}
|
||||||
|
|
||||||
|
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||||
|
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
||||||
|
|
||||||
|
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
|
||||||
|
const bathyFillColor = [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
depth,
|
||||||
|
-11000,
|
||||||
|
'#00040b',
|
||||||
|
-8000,
|
||||||
|
'#010610',
|
||||||
|
-6000,
|
||||||
|
'#020816',
|
||||||
|
-4000,
|
||||||
|
'#030c1c',
|
||||||
|
-2000,
|
||||||
|
'#041022',
|
||||||
|
-1000,
|
||||||
|
'#051529',
|
||||||
|
-500,
|
||||||
|
'#061a30',
|
||||||
|
-200,
|
||||||
|
'#071f36',
|
||||||
|
-100,
|
||||||
|
'#08263d',
|
||||||
|
-50,
|
||||||
|
'#0e3d5e',
|
||||||
|
-20,
|
||||||
|
'#145578',
|
||||||
|
-10,
|
||||||
|
'#1a6e8e',
|
||||||
|
0,
|
||||||
|
'#2097a6',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const bathyFill: LayerSpecification = {
|
||||||
|
id: 'bathymetry-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 5,
|
||||||
|
maxzoom: 24,
|
||||||
|
paint: {
|
||||||
|
'fill-color': bathyFillColor,
|
||||||
|
'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyBandBorders: LayerSpecification = {
|
||||||
|
id: 'bathymetry-borders',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 5,
|
||||||
|
maxzoom: 24,
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.06)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyLinesMinor: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 7,
|
||||||
|
paint: {
|
||||||
|
'line-color': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
depth,
|
||||||
|
-11000,
|
||||||
|
'rgba(255,255,255,0.04)',
|
||||||
|
-6000,
|
||||||
|
'rgba(255,255,255,0.05)',
|
||||||
|
-2000,
|
||||||
|
'rgba(255,255,255,0.07)',
|
||||||
|
0,
|
||||||
|
'rgba(255,255,255,0.10)',
|
||||||
|
],
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
||||||
|
const bathyMajorDepthFilter: unknown[] = [
|
||||||
|
'in',
|
||||||
|
['to-number', ['get', 'depth']],
|
||||||
|
['literal', majorDepths],
|
||||||
|
] as unknown[];
|
||||||
|
|
||||||
|
const bathyLinesMajor: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-major',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 7,
|
||||||
|
maxzoom: 24,
|
||||||
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.16)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyBandBordersMajor: LayerSpecification = {
|
||||||
|
id: 'bathymetry-borders-major',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 3,
|
||||||
|
maxzoom: 24,
|
||||||
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.14)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyLabels: LayerSpecification = {
|
||||||
|
id: 'bathymetry-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 10,
|
||||||
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'text-field': depthLabel,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-padding': 2,
|
||||||
|
'text-rotation-alignment': 'map',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'rgba(226,232,240,0.72)',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.82)',
|
||||||
|
'text-halo-width': 1.0,
|
||||||
|
'text-halo-blur': 0.6,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const landformLabels: LayerSpecification = {
|
||||||
|
id: 'bathymetry-landforms',
|
||||||
|
type: 'symbol',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'landform',
|
||||||
|
minzoom: 8,
|
||||||
|
filter: ['has', 'name'] as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'] as unknown as unknown[],
|
||||||
|
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-offset': [0, 0.0],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'rgba(148,163,184,0.70)',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.0,
|
||||||
|
'text-halo-blur': 0.7,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : [];
|
||||||
|
if (!Array.isArray(style.layers)) {
|
||||||
|
style.layers = layers as unknown as StyleSpecification['layers'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
|
||||||
|
// with the bathymetry gradient instead of appearing as near-black voids.
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const SHALLOW_WATER_FILL = '#14606e';
|
||||||
|
const SHALLOW_WATER_LINE = '#114f5c';
|
||||||
|
for (const layer of layers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (!id) continue;
|
||||||
|
const spec = layer as Record<string, unknown>;
|
||||||
|
const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase();
|
||||||
|
const layerType = String(spec.type ?? '');
|
||||||
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||||
|
if (!isWater) continue;
|
||||||
|
|
||||||
|
const paint = (spec.paint ?? {}) as Record<string, unknown>;
|
||||||
|
if (layerType === 'fill') {
|
||||||
|
paint['fill-color'] = SHALLOW_WATER_FILL;
|
||||||
|
spec.paint = paint;
|
||||||
|
} else if (layerType === 'line') {
|
||||||
|
paint['line-color'] = SHALLOW_WATER_LINE;
|
||||||
|
spec.paint = paint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol');
|
||||||
|
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
|
||||||
|
|
||||||
|
const existingIds = new Set<string>();
|
||||||
|
for (const layer of layers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (id) existingIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInsert = [
|
||||||
|
bathyFill,
|
||||||
|
bathyBandBorders,
|
||||||
|
bathyBandBordersMajor,
|
||||||
|
bathyLinesMinor,
|
||||||
|
bathyLinesMajor,
|
||||||
|
bathyLabels,
|
||||||
|
landformLabels,
|
||||||
|
].filter((l) => !existingIds.has(l.id));
|
||||||
|
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) {
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (baseMap !== 'enhanced') return;
|
||||||
|
const isGlobe = projection === 'globe';
|
||||||
|
|
||||||
|
for (const range of BATHY_ZOOM_RANGES) {
|
||||||
|
if (!map.getLayer(range.id)) continue;
|
||||||
|
const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(range.id, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setLayerZoomRange(range.id, minzoom, maxzoom);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
const key = getMapTilerKey();
|
||||||
|
if (!key) return '/map/styles/osm-seamark.json';
|
||||||
|
|
||||||
|
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim();
|
||||||
|
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
|
||||||
|
|
||||||
|
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||||
|
const json = (await res.json()) as StyleSpecification;
|
||||||
|
injectOceanBathymetryLayers(json, key);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||||
|
return resolveInitialMapStyle(signal);
|
||||||
|
}
|
||||||
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import maplibregl, { type LayerSpecification } from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
|
||||||
|
const srcId = 'seamark';
|
||||||
|
const layerId = 'seamark';
|
||||||
|
|
||||||
|
if (!map.getSource(srcId)) {
|
||||||
|
map.addSource(srcId, {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© OpenSeaMap contributors',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
const layer: LayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: srcId,
|
||||||
|
paint: { 'raster-opacity': 0.85 },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
|
||||||
|
map.addLayer(layer, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { DashSeg } from '../types';
|
||||||
|
|
||||||
|
export function dashifyLine(
|
||||||
|
from: [number, number],
|
||||||
|
to: [number, number],
|
||||||
|
suspicious: boolean,
|
||||||
|
distanceNm?: number,
|
||||||
|
fromMmsi?: number,
|
||||||
|
toMmsi?: number,
|
||||||
|
): DashSeg[] {
|
||||||
|
const segs: DashSeg[] = [];
|
||||||
|
const steps = 14;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
if (i % 2 === 1) continue;
|
||||||
|
const a0 = i / steps;
|
||||||
|
const a1 = (i + 1) / steps;
|
||||||
|
const lon0 = from[0] + (to[0] - from[0]) * a0;
|
||||||
|
const lat0 = from[1] + (to[1] - from[1]) * a0;
|
||||||
|
const lon1 = from[0] + (to[0] - from[0]) * a1;
|
||||||
|
const lat1 = from[1] + (to[1] - from[1]) * a1;
|
||||||
|
segs.push({
|
||||||
|
from: [lon0, lat0],
|
||||||
|
to: [lon1, lat1],
|
||||||
|
suspicious,
|
||||||
|
distanceNm,
|
||||||
|
fromMmsi,
|
||||||
|
toMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function makeOrderedPairKey(a: number, b: number) {
|
||||||
|
const left = Math.trunc(Math.min(a, b));
|
||||||
|
const right = Math.trunc(Math.max(a, b));
|
||||||
|
return `${left}-${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePairLinkFeatureId(a: number, b: number, suffix?: string) {
|
||||||
|
const pair = makeOrderedPairKey(a, b);
|
||||||
|
return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) {
|
||||||
|
const pair = makeOrderedPairKey(a, b);
|
||||||
|
return `fc-${pair}-${segmentIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetCircleFeatureId(ownerKey: string) {
|
||||||
|
return `fleet-${ownerKey}`;
|
||||||
|
}
|
||||||
62
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
62
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { DEG2RAD, RAD2DEG, EARTH_RADIUS_M } from '../constants';
|
||||||
|
|
||||||
|
export const clampNumber = (value: number, minValue: number, maxValue: number) =>
|
||||||
|
Math.max(minValue, Math.min(maxValue, value));
|
||||||
|
|
||||||
|
export function wrapLonDeg(lon: number) {
|
||||||
|
const v = ((lon + 180) % 360 + 360) % 360;
|
||||||
|
return v - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destinationPointLngLat(
|
||||||
|
from: [number, number],
|
||||||
|
bearingDeg: number,
|
||||||
|
distanceMeters: number,
|
||||||
|
): [number, number] {
|
||||||
|
const [lonDeg, latDeg] = from;
|
||||||
|
const lat1 = latDeg * DEG2RAD;
|
||||||
|
const lon1 = lonDeg * DEG2RAD;
|
||||||
|
const brng = bearingDeg * DEG2RAD;
|
||||||
|
const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M;
|
||||||
|
if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg];
|
||||||
|
|
||||||
|
const sinLat1 = Math.sin(lat1);
|
||||||
|
const cosLat1 = Math.cos(lat1);
|
||||||
|
const sinDr = Math.sin(dr);
|
||||||
|
const cosDr = Math.cos(dr);
|
||||||
|
|
||||||
|
const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng));
|
||||||
|
const lon2 =
|
||||||
|
lon1 +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(brng) * sinDr * cosLat1,
|
||||||
|
cosDr - sinLat1 * Math.sin(lat2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outLon = wrapLonDeg(lon2 * RAD2DEG);
|
||||||
|
const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0);
|
||||||
|
return [outLon, outLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] {
|
||||||
|
const [lon0, lat0] = center;
|
||||||
|
const latRad = lat0 * DEG2RAD;
|
||||||
|
const cosLat = Math.max(1e-6, Math.cos(latRad));
|
||||||
|
const r = Math.max(0, radiusMeters);
|
||||||
|
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const a = (i / steps) * Math.PI * 2;
|
||||||
|
const dy = r * Math.sin(a);
|
||||||
|
const dx = r * Math.cos(a);
|
||||||
|
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD;
|
||||||
|
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD;
|
||||||
|
ring.push([lon0 + dLon, lat0 + dLat]);
|
||||||
|
}
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAngleDeg(value: number, offset = 0): number {
|
||||||
|
const v = value + offset;
|
||||||
|
return ((v % 360) + 360) % 360;
|
||||||
|
}
|
||||||
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function buildFallbackGlobeShipIcon() {
|
||||||
|
const size = 96;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,1)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(size / 2, 6);
|
||||||
|
ctx.lineTo(size / 2 - 14, 24);
|
||||||
|
ctx.lineTo(size / 2 - 18, 58);
|
||||||
|
ctx.lineTo(size / 2 - 10, 88);
|
||||||
|
ctx.lineTo(size / 2 + 10, 88);
|
||||||
|
ctx.lineTo(size / 2 + 18, 58);
|
||||||
|
ctx.lineTo(size / 2 + 14, 24);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillRect(size / 2 - 8, 34, 16, 18);
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFallbackGlobeAnchoredShipIcon() {
|
||||||
|
const baseImage = buildFallbackGlobeShipIcon();
|
||||||
|
if (!baseImage) return null;
|
||||||
|
|
||||||
|
const size = baseImage.width;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
ctx.putImageData(baseImage, 0, 0);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(248,250,252,1)';
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
const cx = size / 2;
|
||||||
|
ctx.moveTo(cx - 18, 76);
|
||||||
|
ctx.lineTo(cx + 18, 76);
|
||||||
|
ctx.moveTo(cx, 66);
|
||||||
|
ctx.lineTo(cx, 82);
|
||||||
|
ctx.moveTo(cx, 82);
|
||||||
|
ctx.arc(cx, 82, 7, 0, Math.PI * 2);
|
||||||
|
ctx.moveTo(cx, 82);
|
||||||
|
ctx.lineTo(cx, 88);
|
||||||
|
ctx.moveTo(cx - 9, 88);
|
||||||
|
ctx.lineTo(cx + 9, 88);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureFallbackShipImage(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
imageId: string,
|
||||||
|
fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon,
|
||||||
|
) {
|
||||||
|
if (!map || map.hasImage(imageId)) return;
|
||||||
|
const image = fallbackBuilder();
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
map.addImage(imageId, image, { pixelRatio: 2, sdf: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
118
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
118
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
|
type LayerSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) {
|
||||||
|
if (!layerId) return;
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
||||||
|
try {
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBE_NATIVE_LAYER_IDS = [
|
||||||
|
'ships-globe-halo',
|
||||||
|
'ships-globe-outline',
|
||||||
|
'ships-globe',
|
||||||
|
'ships-globe-label',
|
||||||
|
'ships-globe-hover-halo',
|
||||||
|
'ships-globe-hover-outline',
|
||||||
|
'ships-globe-hover',
|
||||||
|
'pair-lines-ml',
|
||||||
|
'fc-lines-ml',
|
||||||
|
'fleet-circles-ml-fill',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'deck-globe',
|
||||||
|
];
|
||||||
|
|
||||||
|
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||||
|
'ships-globe-src',
|
||||||
|
'ships-globe-hover-src',
|
||||||
|
'pair-lines-ml-src',
|
||||||
|
'fc-lines-ml-src',
|
||||||
|
'fleet-circles-ml-src',
|
||||||
|
'fleet-circles-ml-fill-src',
|
||||||
|
'pair-range-ml-src',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||||
|
for (const id of GLOBE_NATIVE_LAYER_IDS) {
|
||||||
|
removeLayerIfExists(map, id);
|
||||||
|
}
|
||||||
|
for (const id of GLOBE_NATIVE_SOURCE_IDS) {
|
||||||
|
removeSourceIfExists(map, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureGeoJsonSource(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
sourceId: string,
|
||||||
|
data: GeoJSON.GeoJSON,
|
||||||
|
) {
|
||||||
|
const existing = map.getSource(sourceId);
|
||||||
|
if (existing) {
|
||||||
|
(existing as maplibregl.GeoJSONSource).setData(data);
|
||||||
|
} else {
|
||||||
|
map.addSource(sourceId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data,
|
||||||
|
} satisfies GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureLayer(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
spec: LayerSpecification,
|
||||||
|
options?: { before?: string },
|
||||||
|
) {
|
||||||
|
if (map.getLayer(spec.id)) return;
|
||||||
|
const before = options?.before && map.getLayer(options.before) ? options.before : undefined;
|
||||||
|
map.addLayer(spec, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) {
|
||||||
|
if (!map.getLayer(layerId)) return;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupLayers(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
layerIds: string[],
|
||||||
|
sourceIds: string[],
|
||||||
|
) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const id of layerIds) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of sourceIds) {
|
||||||
|
try {
|
||||||
|
if (map.getSource(id)) map.removeSource(id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
124
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
124
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
|
export function kickRepaint(map: maplibregl.Map | null) {
|
||||||
|
if (!map) return;
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore (e.g., non-browser env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) {
|
||||||
|
if (!map) {
|
||||||
|
return () => {
|
||||||
|
// noop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
callback();
|
||||||
|
return () => {
|
||||||
|
// noop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let fired = false;
|
||||||
|
const runOnce = () => {
|
||||||
|
if (!map || fired || !map.isStyleLoaded()) return;
|
||||||
|
fired = true;
|
||||||
|
callback();
|
||||||
|
try {
|
||||||
|
map.off('style.load', runOnce);
|
||||||
|
map.off('styledata', runOnce);
|
||||||
|
map.off('idle', runOnce);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('style.load', runOnce);
|
||||||
|
map.on('styledata', runOnce);
|
||||||
|
map.on('idle', runOnce);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (fired) return;
|
||||||
|
fired = true;
|
||||||
|
try {
|
||||||
|
if (!map) return;
|
||||||
|
map.off('style.load', runOnce);
|
||||||
|
map.off('styledata', runOnce);
|
||||||
|
map.off('idle', runOnce);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined {
|
||||||
|
const projection = map.getProjection?.();
|
||||||
|
if (!projection || typeof projection !== 'object') return undefined;
|
||||||
|
|
||||||
|
const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name;
|
||||||
|
if (rawType === 'globe') return 'globe';
|
||||||
|
if (rawType === 'mercator') return 'mercator';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapTilerKey(): string | null {
|
||||||
|
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||||
|
if (typeof k !== 'string') return null;
|
||||||
|
const v = k.trim();
|
||||||
|
return v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayerId(value: unknown): string | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const candidate = (value as { id?: unknown }).id;
|
||||||
|
return typeof candidate === 'string' ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDeckLayerList(value: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: unknown[] = [];
|
||||||
|
let dropped = 0;
|
||||||
|
|
||||||
|
for (const layer of value) {
|
||||||
|
const layerId = getLayerId(layer);
|
||||||
|
if (!layerId) {
|
||||||
|
dropped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(layerId)) {
|
||||||
|
dropped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(layerId);
|
||||||
|
out.push(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropped > 0 && import.meta.env.DEV) {
|
||||||
|
console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||||
|
const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||||
|
return ['all', inA, inB] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const literal = ['literal', hoveredMmsiList] as unknown[];
|
||||||
|
return [
|
||||||
|
'any',
|
||||||
|
['in', ['to-number', ['get', aField]], literal],
|
||||||
|
['in', ['to-number', ['get', bField]], literal],
|
||||||
|
] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) {
|
||||||
|
if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[];
|
||||||
|
for (const ownerKey of hoveredOwnerKeys) {
|
||||||
|
expr.push(String(ownerKey), true);
|
||||||
|
}
|
||||||
|
expr.push(false);
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const clauses = hoveredFleetMmsiList.map((mmsi) =>
|
||||||
|
['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[],
|
||||||
|
);
|
||||||
|
return ['any', ...clauses] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeGlobeCircleRadiusExpr() {
|
||||||
|
const base3 = 4;
|
||||||
|
const base7 = 6;
|
||||||
|
const base10 = 8;
|
||||||
|
const base14 = 12;
|
||||||
|
const base18 = 32;
|
||||||
|
|
||||||
|
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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||||
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
export function toNumberSet(values: number[] | undefined | null) {
|
||||||
|
const out = new Set<number>();
|
||||||
|
if (!values) return out;
|
||||||
|
for (const value of values) {
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
out.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNumberSets(...sets: Set<number>[]) {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const s of sets) {
|
||||||
|
for (const v of s) {
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSetSignature(values: Set<number>) {
|
||||||
|
return Array.from(values).sort((a, b) => a - b).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSafeNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIntMmsi(value: unknown): number | null {
|
||||||
|
const n = toSafeNumber(value);
|
||||||
|
if (n == null) return null;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === 'number' && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toNumberArray = (values: unknown): number[] => {
|
||||||
|
if (values == null) return [];
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return values as unknown as number[];
|
||||||
|
}
|
||||||
|
if (typeof values === 'number' && Number.isFinite(values)) {
|
||||||
|
return [values];
|
||||||
|
}
|
||||||
|
if (typeof values === 'string') {
|
||||||
|
const value = toSafeNumber(Number(values));
|
||||||
|
return value == null ? [] : [value];
|
||||||
|
}
|
||||||
|
if (typeof values === 'object') {
|
||||||
|
if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === 'function') {
|
||||||
|
try {
|
||||||
|
return Array.from(values as Iterable<unknown>) as number[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeUniqueSorted = (values: unknown) => {
|
||||||
|
const maybeArray = toNumberArray(values);
|
||||||
|
const normalized = Array.isArray(maybeArray) ? maybeArray : [];
|
||||||
|
const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value))));
|
||||||
|
unique.sort((a, b) => a - b);
|
||||||
|
return unique;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const equalNumberArrays = (a: number[], b: number[]) => {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
117
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
117
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||||
|
import {
|
||||||
|
ANCHOR_SPEED_THRESHOLD_KN,
|
||||||
|
LEGACY_CODE_COLORS,
|
||||||
|
MAP_SELECTED_SHIP_RGB,
|
||||||
|
MAP_HIGHLIGHT_SHIP_RGB,
|
||||||
|
MAP_DEFAULT_SHIP_RGB,
|
||||||
|
} from '../constants';
|
||||||
|
import { isFiniteNumber } from './setUtils';
|
||||||
|
import { normalizeAngleDeg } from './geometry';
|
||||||
|
|
||||||
|
export function toValidBearingDeg(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
|
if (value === 511) return null;
|
||||||
|
if (value < 0) return null;
|
||||||
|
if (value >= 360) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnchoredShip({
|
||||||
|
sog,
|
||||||
|
cog,
|
||||||
|
heading,
|
||||||
|
}: {
|
||||||
|
sog: number | null | undefined;
|
||||||
|
cog: number | null | undefined;
|
||||||
|
heading: number | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (!isFiniteNumber(sog)) return true;
|
||||||
|
if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true;
|
||||||
|
return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayHeading({
|
||||||
|
cog,
|
||||||
|
heading,
|
||||||
|
offset = 0,
|
||||||
|
}: {
|
||||||
|
cog: number | null | undefined;
|
||||||
|
heading: number | null | undefined;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0;
|
||||||
|
return normalizeAngleDeg(raw, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
||||||
|
const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGlobeBaseShipColor({
|
||||||
|
legacy,
|
||||||
|
sog,
|
||||||
|
}: {
|
||||||
|
legacy: string | null;
|
||||||
|
sog: number | null;
|
||||||
|
}) {
|
||||||
|
if (legacy) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||||
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)';
|
||||||
|
if (sog >= 10) return 'rgba(148,163,184,0.78)';
|
||||||
|
if (sog >= 1) return 'rgba(100,116,139,0.74)';
|
||||||
|
return 'rgba(71,85,105,0.68)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipColor(
|
||||||
|
t: AisTarget,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
legacyShipCode: string | null,
|
||||||
|
highlightedMmsis: Set<number>,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
if (selectedMmsi && t.mmsi === selectedMmsi) {
|
||||||
|
return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255];
|
||||||
|
}
|
||||||
|
if (highlightedMmsis.has(t.mmsi)) {
|
||||||
|
return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235];
|
||||||
|
}
|
||||||
|
if (legacyShipCode) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
||||||
|
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
||||||
|
return [245, 158, 11, 235];
|
||||||
|
}
|
||||||
|
if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130];
|
||||||
|
if (t.sog >= 10) return [148, 163, 184, 185];
|
||||||
|
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
|
||||||
|
return [71, 85, 105, 165];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGlobeShipFeature(
|
||||||
|
t: AisTarget,
|
||||||
|
legacy: LegacyVesselInfo | undefined,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
highlightedMmsis: Set<number>,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi ? 1 : 0;
|
||||||
|
const isHighlighted = highlightedMmsis.has(t.mmsi) ? 1 : 0;
|
||||||
|
const anchored = isAnchoredShip(t);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||||
|
anchored,
|
||||||
|
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
|
||||||
|
selected: isSelected,
|
||||||
|
highlighted: isHighlighted,
|
||||||
|
permitted: legacy ? 1 : 0,
|
||||||
|
labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '',
|
||||||
|
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
169
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
169
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { isFiniteNumber, toSafeNumber } from './setUtils';
|
||||||
|
|
||||||
|
export function formatNm(value: number | null | undefined) {
|
||||||
|
if (!isFiniteNumber(value)) return '-';
|
||||||
|
return `${value.toFixed(2)} NM`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyTag(legacyHits: Map<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
if (!legacy) return null;
|
||||||
|
return `${legacy.permitNo} (${legacy.shipCode})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargetName(
|
||||||
|
mmsi: number,
|
||||||
|
targetByMmsi: Map<number, AisTarget>,
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined,
|
||||||
|
) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
const target = targetByMmsi.get(mmsi);
|
||||||
|
return (
|
||||||
|
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipTooltipHtml({
|
||||||
|
mmsi,
|
||||||
|
targetByMmsi,
|
||||||
|
legacyHits,
|
||||||
|
}: {
|
||||||
|
mmsi: number;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
}) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
const t = targetByMmsi.get(mmsi);
|
||||||
|
const name = getTargetName(mmsi, targetByMmsi, legacyHits);
|
||||||
|
const sog = isFiniteNumber(t?.sog) ? t.sog : null;
|
||||||
|
const cog = isFiniteNumber(t?.cog) ? t.cog : null;
|
||||||
|
const msg = t?.messageTimestamp ?? null;
|
||||||
|
const vesselType = t?.vesselType || '';
|
||||||
|
|
||||||
|
const legacyHtml = legacy
|
||||||
|
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
||||||
|
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
||||||
|
<div>유효범위: ${legacy.workSeaArea || '-'}</div>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||||
|
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
|
||||||
|
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
|
||||||
|
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ''}
|
||||||
|
${legacyHtml}
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPairLinkTooltipHtml({
|
||||||
|
warn,
|
||||||
|
distanceNm,
|
||||||
|
aMmsi,
|
||||||
|
bMmsi,
|
||||||
|
legacyHits,
|
||||||
|
targetByMmsi,
|
||||||
|
}: {
|
||||||
|
warn: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const a = getTargetName(aMmsi, targetByMmsi, legacyHits);
|
||||||
|
const b = getTargetName(bMmsi, targetByMmsi, legacyHits);
|
||||||
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결</div>
|
||||||
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||||
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFcLinkTooltipHtml({
|
||||||
|
suspicious,
|
||||||
|
distanceNm,
|
||||||
|
fcMmsi,
|
||||||
|
otherMmsi,
|
||||||
|
legacyHits,
|
||||||
|
targetByMmsi,
|
||||||
|
}: {
|
||||||
|
suspicious: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
fcMmsi: number;
|
||||||
|
otherMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const a = getTargetName(fcMmsi, targetByMmsi, legacyHits);
|
||||||
|
const b = getTargetName(otherMmsi, targetByMmsi, legacyHits);
|
||||||
|
const aTag = getLegacyTag(legacyHits, fcMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, otherMmsi);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">환적 연결</div>
|
||||||
|
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">→ ${bTag ?? `MMSI ${otherMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${suspicious ? '의심' : '일반'}</b></div>
|
||||||
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRangeTooltipHtml({
|
||||||
|
warn,
|
||||||
|
distanceNm,
|
||||||
|
aMmsi,
|
||||||
|
bMmsi,
|
||||||
|
legacyHits,
|
||||||
|
}: {
|
||||||
|
warn: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||||
|
const radiusNm = toSafeNumber(distanceNm);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결범위</div>
|
||||||
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">범위: <b>${d}</b> · 반경: <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFleetCircleTooltipHtml({
|
||||||
|
ownerKey,
|
||||||
|
ownerLabel,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
ownerKey: string;
|
||||||
|
ownerLabel?: string;
|
||||||
|
count: number;
|
||||||
|
}) {
|
||||||
|
const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey;
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">선단 범위</div>
|
||||||
|
<div>소유주: ${displayOwner || '-'}</div>
|
||||||
|
<div>선박 수: <b>${count}</b></div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||||
|
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||||
|
|
||||||
|
function toTextValue(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneIdFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||||
|
const safeProps = props || {};
|
||||||
|
const candidates = [
|
||||||
|
'zoneId',
|
||||||
|
'zone_id',
|
||||||
|
'zoneIdNo',
|
||||||
|
'zoneKey',
|
||||||
|
'zoneCode',
|
||||||
|
'ZONE_ID',
|
||||||
|
'ZONECODE',
|
||||||
|
'id',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = toTextValue(safeProps[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneDisplayNameFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||||
|
const safeProps = props || {};
|
||||||
|
const nameCandidates = ['zoneName', 'zoneLabel', 'NAME', 'name', 'ZONE_NM', 'label'];
|
||||||
|
for (const key of nameCandidates) {
|
||||||
|
const name = toTextValue(safeProps[key]);
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
const zoneId = getZoneIdFromProps(safeProps);
|
||||||
|
if (!zoneId) return '수역';
|
||||||
|
return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`;
|
||||||
|
}
|
||||||
72
apps/web/src/widgets/map3d/types.ts
Normal file
72
apps/web/src/widgets/map3d/types.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||||
|
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||||
|
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
|
|
||||||
|
export type Map3DSettings = {
|
||||||
|
showSeamark: boolean;
|
||||||
|
showShips: boolean;
|
||||||
|
showDensity: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseMapId = 'enhanced' | 'legacy';
|
||||||
|
export type MapProjectionId = 'mercator' | 'globe';
|
||||||
|
|
||||||
|
export interface Map3DProps {
|
||||||
|
targets: AisTarget[];
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
hoveredMmsiSet?: number[];
|
||||||
|
hoveredFleetMmsiSet?: number[];
|
||||||
|
hoveredPairMmsiSet?: number[];
|
||||||
|
hoveredFleetOwnerKey?: string | null;
|
||||||
|
highlightedMmsiSet?: number[];
|
||||||
|
settings: Map3DSettings;
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
|
legacyHits?: Map<number, LegacyVesselInfo> | null;
|
||||||
|
pairLinks?: PairLink[];
|
||||||
|
fcLinks?: FcLink[];
|
||||||
|
fleetCircles?: FleetCircle[];
|
||||||
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||||
|
fleetFocus?: {
|
||||||
|
id: string | number;
|
||||||
|
center: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
};
|
||||||
|
onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void;
|
||||||
|
onClearFleetHover?: () => void;
|
||||||
|
onHoverMmsi?: (mmsiList: number[]) => void;
|
||||||
|
onClearMmsiHover?: () => void;
|
||||||
|
onHoverPair?: (mmsiList: number[]) => void;
|
||||||
|
onClearPairHover?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashSeg = {
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
suspicious: boolean;
|
||||||
|
distanceNm?: number;
|
||||||
|
fromMmsi?: number;
|
||||||
|
toMmsi?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairRangeCircle = {
|
||||||
|
center: [number, number]; // [lon, lat]
|
||||||
|
radiusNm: number;
|
||||||
|
warn: boolean;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
distanceNm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BathyZoomRange = {
|
||||||
|
id: string;
|
||||||
|
mercator: [number, number];
|
||||||
|
globe: [number, number];
|
||||||
|
};
|
||||||
불러오는 중...
Reference in New Issue
Block a user