refactor(map): Map3D 모듈 분리 + 버그 4건 수정 + 수심 색상 개선

Map3D.tsx 단일 파일(5752줄)에서 1200줄을 16개 모듈로 추출하여
탐색성과 유지보수성 개선.

모듈 구조:
- types.ts, constants.ts: 타입/상수 정의
- lib/: setUtils, geometry, featureIds, mlExpressions, shipUtils,
  tooltips, globeShipIcon, mapCore, dashifyLine, layerHelpers, zoneUtils
- layers/: bathymetry, seamark
- hooks/: useHoverState

버그 수정:
- fix: Globe 선박 라벨 미표시 (permitted boolean→number + filter 갱신)
- fix: placement TypeError (isStyleLoaded 가드 + epoch change 시 remove 제거)
- fix: Globe easeTo 미지원 경고 (globe 모드에서 flyTo 사용)
- fix: 수심지도 얕은 구간 색상 미구분 (색상 팔레트 개선)

개선:
- 베이스맵 water 레이어 색상을 수심 그라데이션과 자연스럽게 연결
- 프로젝션 전환 settle 로직 최적화 (더블프레임→싱글프레임)
- glyphs URL 추가로 symbol 레이어 텍스트 렌더링 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-15 23:57:38 +09:00
부모 918b80e06a
커밋 51090aca2a
19개의 변경된 파일1604개의 추가작업 그리고 1337개의 파일을 삭제

파일 보기

@ -1,6 +1,7 @@
{
"version": 8,
"name": "CARTO Dark (Legacy)",
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
"sources": {
"carto-dark": {
"type": "raster",

파일 보기

@ -1,6 +1,7 @@
{
"version": 8,
"name": "OSM Raster + OpenSeaMap",
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
"sources": {
"osm": {
"type": "raster",

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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] },
];

파일 보기

@ -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,
};
}

파일 보기

@ -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);
}

파일 보기

@ -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);
}
}

파일 보기

@ -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;
}

파일 보기

@ -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}`;
}

파일 보기

@ -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;
}

파일 보기

@ -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
}
}

파일 보기

@ -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
}
}
});
}

파일 보기

@ -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;
}

파일 보기

@ -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;

파일 보기

@ -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;
};

파일 보기

@ -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})` : '',
};
}

파일 보기

@ -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>`,
};
}

파일 보기

@ -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}`;
}

파일 보기

@ -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];
};