refactor(map): Map3D 모듈 분리 및 버그 수정
This commit is contained in:
부모
918b80e06a
커밋
324c6267f0
@ -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",
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
158
apps/web/src/widgets/map3d/constants.ts
Normal file
158
apps/web/src/widgets/map3d/constants.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
// ── 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: [6, 24], globe: [8, 24] },
|
||||||
|
{ id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] },
|
||||||
|
{ id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] },
|
||||||
|
];
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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: 6,
|
||||||
|
maxzoom: 24,
|
||||||
|
paint: {
|
||||||
|
'fill-color': bathyFillColor,
|
||||||
|
'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 6, 0.86, 10, 0.78],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const bathyBandBorders: LayerSpecification = {
|
||||||
|
id: 'bathymetry-borders',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 6,
|
||||||
|
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: 8,
|
||||||
|
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: 8,
|
||||||
|
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: 4,
|
||||||
|
maxzoom: 24,
|
||||||
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.14)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.14, 8, 0.2, 12, 0.26],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.3, 10, 0.15],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.35, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
62
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
|
type LayerSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
65
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
65
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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 = 11;
|
||||||
|
|
||||||
|
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], 11.8, ['==', ['get', 'highlighted'], 1], 10.8, base14],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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