Compare commits

...

5 커밋

작성자 SHA1 메시지 날짜
ca5560aff2 feat(map): 해저케이블 레이어 및 정보 패널 구현
- subcable entity 생성 (타입 정의 + 데이터 로딩 hook)
- MapLibre 레이어: 케이블 라인 + 호버 하이라이트 + 라벨
- 지도 표시 설정에 해저케이블 토글 추가
- 클릭 시 우측 정보 패널 (길이, 개통, 운영사, landing points)
- Map3D + DashboardPage 통합

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:17:58 +09:00
621a5037c2 chore(data): vendor submarine cable geojson/details 2026-02-16 02:17:55 +09:00
3ba6c02ba0 feat(map): 선박 외곽선 대비 및 줌 스케일링 개선 2026-02-16 01:10:45 +09:00
864fc44d0e refactor(map): Map3D.tsx hooks 추출 완료 (4558줄 → 510줄) 2026-02-16 00:41:11 +09:00
324c6267f0 refactor(map): Map3D 모듈 분리 및 버그 수정 2026-02-15 23:57:38 +09:00
39개의 변경된 파일6112개의 추가작업 그리고 5384개의 파일을 삭제

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -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",

파일 보기

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types';
interface SubcableData {
geo: SubcableGeoJson;
details: Map<string, SubcableDetail>;
}
export function useSubcables(
geoUrl = '/data/subcables/cable-geo.json',
detailsUrl = '/data/subcables/cable-details.min.json',
) {
const [data, setData] = useState<SubcableData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
try {
setError(null);
const [geoRes, detailsRes] = await Promise.all([
fetch(geoUrl),
fetch(detailsUrl),
]);
if (!geoRes.ok) throw new Error(`Failed to load subcable geo: ${geoRes.status}`);
if (!detailsRes.ok) throw new Error(`Failed to load subcable details: ${detailsRes.status}`);
const geo = (await geoRes.json()) as SubcableGeoJson;
const detailsJson = (await detailsRes.json()) as SubcableDetailsIndex;
if (cancelled) return;
const details = new Map<string, SubcableDetail>();
for (const [id, detail] of Object.entries(detailsJson.by_id)) {
details.set(id, detail);
}
setData({ geo, details });
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
}
}
void run();
return () => {
cancelled = true;
};
}, [geoUrl, detailsUrl]);
return { data, error };
}

파일 보기

@ -0,0 +1,39 @@
export interface SubcableFeatureProperties {
id: string;
name: string;
color: string;
feature_id: string;
coordinates: [number, number];
}
export type SubcableGeoJson = GeoJSON.FeatureCollection<
GeoJSON.MultiLineString,
SubcableFeatureProperties
>;
export interface SubcableLandingPoint {
id: string;
name: string;
country: string;
is_tbd: boolean;
}
export interface SubcableDetail {
id: string;
name: string;
length: string | null;
rfs: string | null;
rfs_year: number | null;
is_planned: boolean;
owners: string | null;
suppliers: string | null;
landing_points: SubcableLandingPoint[];
notes: string | null;
url: string | null;
}
export interface SubcableDetailsIndex {
version: number;
generated_at: string;
by_id: Record<string, SubcableDetail>;
}

파일 보기

@ -6,6 +6,7 @@ export type MapToggleState = {
fleetCircles: boolean;
predictVectors: boolean;
shipLabels: boolean;
subcables: boolean;
};
type Props = {
@ -22,6 +23,7 @@ export function MapToggles({ value, onToggle }: Props) {
{ id: "zones", label: "수역 표시" },
{ id: "predictVectors", label: "예측 벡터" },
{ id: "shipLabels", label: "선박명 표시" },
{ id: "subcables", label: "해저케이블" },
];
return (

파일 보기

@ -11,6 +11,7 @@ import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
import { useZones } from "../../entities/zone/api/useZones";
import { useSubcables } from "../../entities/subcable/api/useSubcables";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
@ -21,6 +22,7 @@ import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
import { VesselList } from "../../widgets/vesselList/VesselList";
import {
buildLegacyHitMap,
@ -70,6 +72,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
export function DashboardPage() {
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables();
const legacyIndex = useLegacyIndex(legacyData);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
@ -115,8 +118,9 @@ export function DashboardPage() {
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: false,
shipLabels: false,
predictVectors: true,
shipLabels: true,
subcables: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
@ -126,6 +130,9 @@ export function DashboardPage() {
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
const [settings, setSettings] = useState<Map3DSettings>({
showShips: true,
showDensity: false,
@ -711,6 +718,10 @@ export function DashboardPage() {
setHoveredFleetOwnerKey(null);
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
}}
subcableGeo={subcableData?.geo ?? null}
hoveredCableId={hoveredCableId}
onHoverCable={setHoveredCableId}
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
/>
<MapLegend />
{selectedLegacyVessel ? (
@ -718,6 +729,13 @@ export function DashboardPage() {
) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
) : null}
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
<SubcableInfoPanel
detail={subcableData.details.get(selectedCableId)!}
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
onClose={() => setSelectedCableId(null)}
/>
) : null}
</div>
</div>
);

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

파일 보기

@ -0,0 +1,166 @@
import {
LEGACY_CODE_COLORS_RGB,
OVERLAY_RGB,
rgba as rgbaCss,
} from '../../shared/lib/map/palette';
import type { BathyZoomRange } from './types';
// ── Re-export palette aliases used throughout Map3D ──
export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB;
const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal;
const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn;
const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
// ── Ship icon mapping (Deck.gl IconLayer) ──
export const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
// ── Ship constants ──
export const ANCHOR_SPEED_THRESHOLD_KN = 1;
export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon';
// ── Geometry constants ──
export const DEG2RAD = Math.PI / 180;
export const RAD2DEG = 180 / Math.PI;
export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
export const GLOBE_ICON_HEADING_OFFSET_DEG = 0;
// ── Ship color constants ──
export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11];
export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139];
// ── Ship outline / halo contrast colors ──
export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 240, 155];
export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230];
export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210];
export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)';
export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)';
// ── Flat map icon sizes ──
export const FLAT_SHIP_ICON_SIZE = 19;
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
export const FLAT_LEGACY_HALO_RADIUS = 14;
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
export const EMPTY_MMSI_SET = new Set<number>();
// ── Deck.gl view ID ──
export const DECK_VIEW_ID = 'mapbox';
// ── Depth params ──
export const DEPTH_DISABLED_PARAMS = {
depthCompare: 'always',
depthWriteEnabled: false,
} as const;
export const GLOBE_OVERLAY_PARAMS = {
depthCompare: 'less-equal',
depthWriteEnabled: false,
} as const;
// ── Deck.gl color constants (avoid per-object allocations inside accessors) ──
export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110,
];
export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170,
];
export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85,
];
export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220,
];
export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200,
];
export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220,
];
export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
];
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
];
// ── Highlighted variants ──
export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200,
];
export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240,
];
export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245,
];
export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245,
];
export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235,
];
export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245,
];
export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
];
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
];
// ── MapLibre overlay colors ──
export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55);
export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95);
export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95);
export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98);
export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45);
export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75);
export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92);
export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92);
export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92);
export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
// ── Bathymetry zoom ranges ──
export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] },
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] },
];

파일 보기

@ -0,0 +1,132 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { BaseMapId, MapProjectionId } from '../types';
import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore';
import { ensureSeamarkOverlay } from '../layers/seamark';
import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry';
export function useBaseMapToggle(
mapRef: MutableRefObject<maplibregl.Map | null>,
opts: {
baseMap: BaseMapId;
projection: MapProjectionId;
showSeamark: boolean;
mapSyncEpoch: number;
pulseMapSync: () => void;
},
) {
const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts;
const showSeamarkRef = useRef(showSeamark);
const bathyZoomProfileKeyRef = useRef<string>('');
useEffect(() => {
showSeamarkRef.current = showSeamark;
}, [showSeamark]);
// Base map style toggle
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
const controller = new AbortController();
let stop: (() => void) | null = null;
(async () => {
try {
const style = await resolveMapStyle(baseMap, controller.signal);
if (cancelled) return;
map.setStyle(style, { diff: false });
stop = onMapStyleReady(map, () => {
kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map));
pulseMapSync();
});
} catch (e) {
if (cancelled) return;
console.warn('Base map switch failed:', e);
}
})();
return () => {
cancelled = true;
controller.abort();
stop?.();
};
}, [baseMap]);
// Bathymetry zoom profile + water layer visibility
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (!map.isStyleLoaded()) return;
const seaVisibility = 'visible' as const;
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`;
if (bathyZoomProfileKeyRef.current !== nextProfileKey) {
applyBathymetryZoomProfile(map, baseMap, projection);
bathyZoomProfileKeyRef.current = nextProfileKey;
kickRepaint(map);
}
try {
const style = map.getStyle();
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
for (const layer of styleLayers) {
const id = getLayerId(layer);
if (!id) continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '').toLowerCase();
const source = String((layer as { source?: unknown }).source ?? '').toLowerCase();
const type = String((layer as { type?: unknown }).type ?? '').toLowerCase();
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
const isRaster = type === 'raster';
if (!isSea) continue;
if (!map.getLayer(id)) continue;
if (isRaster && id === 'seamark') continue;
try {
map.setLayoutProperty(id, 'visibility', seaVisibility);
} catch {
// ignore
}
}
} catch {
// ignore
}
};
const stop = onMapStyleReady(map, apply);
return () => {
stop();
};
}, [projection, baseMap, mapSyncEpoch]);
// Seamark toggle
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (showSeamark) {
try {
ensureSeamarkOverlay(map, 'bathymetry-lines');
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
} catch {
// ignore until style is ready
}
return;
}
try {
if (map.getLayer('seamark')) map.removeLayer('seamark');
} catch {
// ignore
}
try {
if (map.getSource('seamark')) map.removeSource('seamark');
} catch {
// ignore
}
}, [showSeamark]);
}

파일 보기

@ -0,0 +1,662 @@
import { useEffect, useMemo, type MutableRefObject } from 'react';
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { type PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import {
SHIP_ICON_MAPPING,
FLAT_SHIP_ICON_SIZE,
FLAT_SHIP_ICON_SIZE_SELECTED,
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
FLAT_LEGACY_HALO_RADIUS,
FLAT_LEGACY_HALO_RADIUS_SELECTED,
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
EMPTY_MMSI_SET,
DEPTH_DISABLED_PARAMS,
GLOBE_OVERLAY_PARAMS,
HALO_OUTLINE_COLOR,
HALO_OUTLINE_COLOR_SELECTED,
HALO_OUTLINE_COLOR_HIGHLIGHTED,
PAIR_RANGE_NORMAL_DECK,
PAIR_RANGE_WARN_DECK,
PAIR_LINE_NORMAL_DECK,
PAIR_LINE_WARN_DECK,
FC_LINE_NORMAL_DECK,
FC_LINE_SUSPICIOUS_DECK,
FLEET_RANGE_LINE_DECK,
FLEET_RANGE_FILL_DECK,
PAIR_RANGE_NORMAL_DECK_HL,
PAIR_RANGE_WARN_DECK_HL,
PAIR_LINE_NORMAL_DECK_HL,
PAIR_LINE_WARN_DECK_HL,
FC_LINE_NORMAL_DECK_HL,
FC_LINE_SUSPICIOUS_DECK_HL,
FLEET_RANGE_LINE_DECK_HL,
FLEET_RANGE_FILL_DECK_HL,
} from '../constants';
import { toSafeNumber } from '../lib/setUtils';
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
import {
getShipTooltipHtml,
getPairLinkTooltipHtml,
getFcLinkTooltipHtml,
getRangeTooltipHtml,
getFleetCircleTooltipHtml,
} from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore';
export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
projectionBusyRef: MutableRefObject<boolean>,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
shipData: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
pairLinks: PairLink[] | undefined;
fcLinks: FcLink[] | undefined;
fcDashed: DashSeg[];
fleetCircles: FleetCircle[] | undefined;
pairRanges: PairRangeCircle[];
pairLinksInteractive: PairLink[];
pairRangesInteractive: PairRangeCircle[];
fcLinesInteractive: DashSeg[];
fleetCirclesInteractive: FleetCircle[];
overlays: MapToggleState;
shipByMmsi: Map<number, AisTarget>;
selectedMmsi: number | null;
shipHighlightSet: Set<number>;
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
isHighlightedMmsi: (mmsi: number) => boolean;
clearDeckHoverPairs: () => void;
clearDeckHoverMmsi: () => void;
clearMapFleetHoverState: () => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
toFleetMmsiList: (value: unknown) => number[];
touchDeckHoverState: (isHover: boolean) => void;
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
ensureMercatorOverlay: () => MapboxOverlay | null;
projectionRef: MutableRefObject<MapProjectionId>;
},
) {
const {
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
isHighlightedFleet, isHighlightedPair, isHighlightedMmsi,
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, projectionRef,
} = opts;
const legacyTargets = useMemo(() => {
if (!legacyHits) return [];
return shipData.filter((t) => legacyHits.has(t.mmsi));
}, [shipData, legacyHits]);
const legacyTargetsOrdered = useMemo(() => {
if (legacyTargets.length === 0) return legacyTargets;
const layer = [...legacyTargets];
layer.sort((a, b) => a.mmsi - b.mmsi);
return layer;
}, [legacyTargets]);
const legacyOverlayTargets = useMemo(() => {
if (shipHighlightSet.size === 0) return [];
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
}, [legacyTargets, shipHighlightSet]);
// Mercator Deck layers
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (projection !== 'mercator' || projectionBusyRef.current) {
if (projection !== 'mercator') {
try {
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
} catch {
// ignore
}
}
return;
}
const deckTarget = ensureMercatorOverlay();
if (!deckTarget) return;
const layers: unknown[] = [];
const overlayParams = DEPTH_DISABLED_PARAMS;
const clearDeckHover = () => {
touchDeckHoverState(false);
};
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
const shipOtherData: AisTarget[] = [];
const shipTargetData: AisTarget[] = [];
for (const t of shipLayerData) {
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
else shipOtherData.push(t);
}
const shipOverlayOtherData: AisTarget[] = [];
const shipOverlayTargetData: AisTarget[] = [];
for (const t of shipOverlayLayerData) {
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
else shipOverlayOtherData.push(t);
}
if (settings.showDensity) {
layers.push(
new HexagonLayer<AisTarget>({
id: 'density',
data: shipLayerData,
pickable: true,
extruded: true,
radius: 2500,
elevationScale: 35,
coverage: 0.92,
opacity: 0.35,
getPosition: (d) => [d.lon, d.lat],
}),
);
}
if (overlays.pairRange && pairRanges.length > 0) {
layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: 'pair-range',
data: pairRanges,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: 'pixels',
getLineWidth: () => 1,
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const p = info.object as PairRangeCircle;
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) { onSelectMmsi(null); return; }
const obj = info.object as PairRangeCircle;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.aMmsi);
onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
layers.push(
new LineLayer<PairLink>({
id: 'pair-lines',
data: pairLinks,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
getWidth: (d) => (d.warn ? 2.2 : 1.4),
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as PairLink;
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as PairLink;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.aMmsi);
onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
if (overlays.fcLines && fcDashed.length > 0) {
layers.push(
new LineLayer<DashSeg>({
id: 'fc-lines',
data: fcDashed,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
getWidth: () => 1.3,
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) return;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.fromMmsi);
onToggleHighlightMmsi?.(obj.toMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
},
}),
);
}
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles',
data: fleetCircles,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: 'pixels',
getLineWidth: () => 1.1,
getLineColor: () => FLEET_RANGE_LINE_DECK,
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as FleetCircle;
const list = toFleetMmsiList(obj.vesselMmsis);
setMapFleetHoverState(obj.ownerKey || null, list);
setDeckHoverMmsi(list);
clearDeckHoverPairs();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as FleetCircle;
const list = toFleetMmsiList(obj.vesselMmsis);
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
return;
}
const first = list[0];
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
},
}),
);
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles-fill',
data: fleetCircles,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
getFillColor: () => FLEET_RANGE_FILL_DECK,
getPosition: (d) => d.center,
}),
);
}
if (settings.showShips) {
const shipOnHover = (info: PickingInfo) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as AisTarget;
setDeckHoverMmsi([obj.mmsi]);
clearDeckHoverPairs();
clearMapFleetHoverState();
};
const shipOnClick = (info: PickingInfo) => {
if (!info.object) { onSelectMmsi(null); return; }
onDeckSelectOrHighlight(
{
mmsi: (info.object as AisTarget).mmsi,
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
},
true,
);
};
if (shipOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-other',
data: shipOtherData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
if (shipOverlayOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-overlay-other',
data: shipOverlayOtherData,
pickable: false,
billboard: false,
parameters: overlayParams,
iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: (d) => {
if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
return 0;
},
getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
alphaCutoff: 0.05,
}),
);
}
if (legacyTargetsOrdered.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'legacy-halo',
data: legacyTargetsOrdered,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
lineWidthUnits: 'pixels',
getLineWidth: () => 2,
getLineColor: () => HALO_OUTLINE_COLOR,
getPosition: (d) => [d.lon, d.lat] as [number, number],
}),
);
}
if (shipTargetData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-target',
data: shipTargetData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
}
if (overlays.pairRange && pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (overlays.pairLines && pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
}
if (overlays.fcLines && fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
}
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
}
if (settings.showShips && legacyOverlayTargets.length > 0) {
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
}
const normalizedLayers = sanitizeDeckLayerList(layers);
const deckProps = {
layers: normalizedLayers,
getTooltip: (info: PickingInfo) => {
if (!info.object) return null;
if (info.layer && info.layer.id === 'density') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const o: any = info.object;
const n = Array.isArray(o?.points) ? o.points.length : 0;
return { text: `AIS density: ${n}` };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object;
if (typeof obj.mmsi === 'number') {
return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
}
if (info.layer && info.layer.id === 'pair-lines') {
const aMmsi = toSafeNumber(obj.aMmsi);
const bMmsi = toSafeNumber(obj.bMmsi);
if (aMmsi == null || bMmsi == null) return null;
return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi });
}
if (info.layer && info.layer.id === 'fc-lines') {
const fcMmsi = toSafeNumber(obj.fcMmsi);
const otherMmsi = toSafeNumber(obj.otherMmsi);
if (fcMmsi == null || otherMmsi == null) return null;
return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi });
}
if (info.layer && info.layer.id === 'pair-range') {
const aMmsi = toSafeNumber(obj.aMmsi);
const bMmsi = toSafeNumber(obj.bMmsi);
if (aMmsi == null || bMmsi == null) return null;
return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits });
}
if (info.layer && info.layer.id === 'fleet-circles') {
return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) });
}
return null;
},
onClick: (info: PickingInfo) => {
if (!info.object) { onSelectMmsi(null); return; }
if (info.layer && info.layer.id === 'density') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object;
if (typeof obj.mmsi === 'number') {
const t = obj as AisTarget;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(t.mmsi);
return;
}
onSelectMmsi(t.mmsi);
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
if (projectionRef.current === 'globe') {
map.flyTo(clickOpts);
} else {
map.easeTo(clickOpts);
}
}
},
};
try {
deckTarget.setProps(deckProps as never);
} catch (e) {
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
try {
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
} catch {
// Ignore secondary failure.
}
}
}, [
ensureMercatorOverlay,
projection,
shipLayerData,
shipByMmsi,
pairRanges,
pairLinks,
fcDashed,
fleetCircles,
legacyTargetsOrdered,
legacyHits,
legacyOverlayTargets,
shipOverlayLayerData,
pairRangesInteractive,
pairLinksInteractive,
fcLinesInteractive,
fleetCirclesInteractive,
overlays.pairRange,
overlays.pairLines,
overlays.fcLines,
overlays.fleetCircles,
settings.showDensity,
settings.showShips,
onDeckSelectOrHighlight,
onSelectMmsi,
onToggleHighlightMmsi,
setDeckHoverPairs,
clearMapFleetHoverState,
setDeckHoverMmsi,
clearDeckHoverMmsi,
toFleetMmsiList,
touchDeckHoverState,
hasAuxiliarySelectModifier,
]);
// Globe Deck overlay
useEffect(() => {
const map = mapRef.current;
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
const deckTarget = globeDeckLayerRef.current;
if (!deckTarget) return;
const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = [];
if (overlays.pairRange && pairRanges.length > 0) {
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
const links = pairLinks || [];
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.fcLines && fcDashed.length > 0) {
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
const circles = fleetCircles || [];
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
}
if (settings.showShips && legacyTargetsOrdered.length > 0) {
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
try {
deckTarget.setProps(globeDeckProps as never);
} catch (e) {
console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e);
try {
deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never);
} catch {
// Ignore secondary failure.
}
}
}, [
projection,
pairRanges,
pairLinks,
fcDashed,
fleetCircles,
legacyTargetsOrdered,
overlays.pairRange,
overlays.pairLines,
overlays.fcLines,
overlays.fleetCircles,
settings.showShips,
selectedMmsi,
isHighlightedFleet,
isHighlightedPair,
clearDeckHoverPairs,
clearDeckHoverMmsi,
clearMapFleetHoverState,
setDeckHoverPairs,
setDeckHoverMmsi,
setMapFleetHoverState,
toFleetMmsiList,
touchDeckHoverState,
legacyHits,
]);
}

파일 보기

@ -0,0 +1,61 @@
import { useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { onMapStyleReady } from '../lib/mapCore';
import type { MapProjectionId } from '../types';
export function useFlyTo(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionRef: MutableRefObject<MapProjectionId>,
opts: {
selectedMmsi: number | null;
shipData: { mmsi: number; lon: number; lat: number }[];
fleetFocusId: string | number | undefined;
fleetFocusLon: number | undefined;
fleetFocusLat: number | undefined;
fleetFocusZoom: number | undefined;
},
) {
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!selectedMmsi) return;
const t = shipData.find((x) => x.mmsi === selectedMmsi);
if (!t) return;
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
}, [selectedMmsi, shipData]);
useEffect(() => {
const map = mapRef.current;
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
return;
const lon = fleetFocusLon;
const lat = fleetFocusLat;
const zoom = fleetFocusZoom ?? 10;
const apply = () => {
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
};
if (map.isStyleLoaded()) {
apply();
return;
}
const stop = onMapStyleReady(map, apply);
return () => {
stop();
};
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
}

파일 보기

@ -0,0 +1,318 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import maplibregl from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import { toIntMmsi, toSafeNumber } from '../lib/setUtils';
import {
getShipTooltipHtml,
getPairLinkTooltipHtml,
getFcLinkTooltipHtml,
getRangeTooltipHtml,
getFleetCircleTooltipHtml,
} from '../lib/tooltips';
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
export function useGlobeInteraction(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
overlays: MapToggleState;
targets: AisTarget[];
shipData: AisTarget[];
shipByMmsi: Map<number, AisTarget>;
selectedMmsi: number | null;
hoveredZoneId: string | null;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
clearDeckHoverPairs: () => void;
clearDeckHoverMmsi: () => void;
clearMapFleetHoverState: () => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
setHoveredZoneId: (updater: (prev: string | null) => string | null) => void;
},
) {
const {
projection, legacyHits, shipByMmsi,
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId,
} = opts;
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
const clearGlobeTooltip = useCallback(() => {
if (!mapTooltipRef.current) return;
try {
mapTooltipRef.current.remove();
} catch {
// ignore
}
mapTooltipRef.current = null;
}, []);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (!mapTooltipRef.current) {
mapTooltipRef.current = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
maxWidth: '360px',
className: 'maplibre-tooltip-popup',
});
}
const container = document.createElement('div');
container.className = 'maplibre-tooltip-popup__content';
container.innerHTML = tooltipHtml;
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
}, []);
const buildGlobeFeatureTooltip = useCallback(
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
if (!feature) return null;
const props = feature.properties || {};
const layerId = feature.layer?.id;
const maybeMmsi = toIntMmsi(props.mmsi);
if (maybeMmsi != null && maybeMmsi > 0) {
return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits });
}
if (layerId === 'pair-lines-ml') {
const warn = props.warn === true;
const aMmsi = toIntMmsi(props.aMmsi);
const bMmsi = toIntMmsi(props.bMmsi);
if (aMmsi == null || bMmsi == null) return null;
return getPairLinkTooltipHtml({
warn, distanceNm: toSafeNumber(props.distanceNm),
aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi,
});
}
if (layerId === 'fc-lines-ml') {
const fcMmsi = toIntMmsi(props.fcMmsi);
const otherMmsi = toIntMmsi(props.otherMmsi);
if (fcMmsi == null || otherMmsi == null) return null;
return getFcLinkTooltipHtml({
suspicious: props.suspicious === true,
distanceNm: toSafeNumber(props.distanceNm),
fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi,
});
}
if (layerId === 'pair-range-ml') {
const aMmsi = toIntMmsi(props.aMmsi);
const bMmsi = toIntMmsi(props.bMmsi);
if (aMmsi == null || bMmsi == null) return null;
return getRangeTooltipHtml({
warn: props.warn === true,
distanceNm: toSafeNumber(props.distanceNm),
aMmsi, bMmsi, legacyHits,
});
}
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
return getFleetCircleTooltipHtml({
ownerKey: String(props.ownerKey ?? ''),
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
count: Number(props.count ?? 0),
});
}
const zoneLabel = getZoneDisplayNameFromProps(props);
if (zoneLabel) {
return { html: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
}
return null;
},
[legacyHits, shipByMmsi],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const clearDeckGlobeHoverState = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState();
};
const resetGlobeHoverStates = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState();
};
const normalizeMmsiList = (value: unknown): number[] => {
if (!Array.isArray(value)) return [];
const out: number[] = [];
for (const n of value) {
const m = toIntMmsi(n);
if (m != null) out.push(m);
}
return out;
};
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
if (projection !== 'globe') {
clearGlobeTooltip();
resetGlobeHoverStates();
return;
}
if (projectionBusyRef.current) {
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
if (!map.isStyleLoaded()) {
clearDeckGlobeHoverState();
clearGlobeTooltip();
return;
}
let candidateLayerIds: string[] = [];
try {
candidateLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml',
'fleet-circles-ml', 'fleet-circles-ml-fill',
'pair-range-ml',
'zones-fill', 'zones-line', 'zones-label',
].filter((id) => map.getLayer(id));
} catch {
candidateLayerIds = [];
}
if (candidateLayerIds.length === 0) {
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
try {
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
properties?: Record<string, unknown> | null;
layer?: { id?: string };
}>;
} catch {
rendered = [];
}
const priority = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
'fleet-circles-ml-fill', 'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label',
];
const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as
| { properties?: Record<string, unknown> | null; layer?: { id?: string } }
| undefined;
if (!first) {
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
const layerId = first.layer?.id;
const props = first.properties || {};
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
if (isShipLayer) {
const mmsi = toIntMmsi(props.mmsi);
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
clearDeckHoverPairs();
clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isPairLayer) {
const aMmsi = toIntMmsi(props.aMmsi);
const bMmsi = toIntMmsi(props.bMmsi);
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
clearDeckHoverMmsi();
clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isFcLayer) {
const from = toIntMmsi(props.fcMmsi);
const to = toIntMmsi(props.otherMmsi);
const fromTo = [from, to].filter((v): v is number => v != null);
setDeckHoverPairs(fromTo);
setDeckHoverMmsi(fromTo);
clearMapFleetHoverState();
setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isFleetLayer) {
const ownerKey = String(props.ownerKey ?? '');
const list = normalizeMmsiList(props.vesselMmsis);
setMapFleetHoverState(ownerKey || null, list);
clearDeckHoverMmsi();
clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null));
} else if (isZoneLayer) {
clearMapFleetHoverState();
clearDeckHoverMmsi();
clearDeckHoverPairs();
const zoneId = getZoneIdFromProps(props);
setHoveredZoneId(() => zoneId || null);
} else {
resetGlobeHoverStates();
}
const tooltip = buildGlobeFeatureTooltip(first);
if (!tooltip) {
if (!isZoneLayer) {
resetGlobeHoverStates();
}
clearGlobeTooltip();
return;
}
const content = tooltip?.html ?? '';
if (content) {
setGlobeTooltip(e.lngLat, content);
return;
}
clearGlobeTooltip();
};
const onMouseOut = () => {
resetGlobeHoverStates();
clearGlobeTooltip();
};
map.on('mousemove', onMouseMove);
map.on('mouseout', onMouseOut);
return () => {
map.off('mousemove', onMouseMove);
map.off('mouseout', onMouseOut);
clearGlobeTooltip();
};
}, [
projection,
buildGlobeFeatureTooltip,
clearGlobeTooltip,
clearMapFleetHoverState,
clearDeckHoverPairs,
clearDeckHoverMmsi,
setDeckHoverPairs,
setDeckHoverMmsi,
setMapFleetHoverState,
setGlobeTooltip,
]);
}

파일 보기

@ -0,0 +1,618 @@
import { useCallback, useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
import {
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_FILL_ML, FLEET_FILL_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
import {
makePairLinkFeatureId,
makeFcSegmentFeatureId,
makeFleetCircleFeatureId,
} from '../lib/featureIds';
import {
makeMmsiPairHighlightExpr,
makeMmsiAnyEndpointExpr,
makeFleetOwnerMatchExpr,
makeFleetMemberMatchExpr,
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { dashifyLine } from '../lib/dashifyLine';
export function useGlobeOverlays(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
pairLinks: PairLink[] | undefined;
fcLinks: FcLink[] | undefined;
fleetCircles: FleetCircle[] | undefined;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredFleetMmsiList: number[];
hoveredFleetOwnerKeyList: string[];
hoveredPairMmsiList: number[];
},
) {
const {
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
} = opts;
// Pair lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-lines-ml-src';
const layerId = 'pair-lines-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (pairLinks || []).map((p) => ({
type: 'Feature',
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
properties: {
type: 'pair',
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
warn: p.warn,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Pair lines source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
['boolean', ['get', 'warn'], false],
PAIR_LINE_WARN_ML,
PAIR_LINE_NORMAL_ML,
] as never,
'line-width': [
'case',
['==', ['get', 'highlighted'], 1], 2.8,
['boolean', ['get', 'warn'], false], 2.2,
1.4,
] as never,
'line-opacity': 0.9,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Pair lines layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// FC lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fc-lines-ml-src';
const layerId = 'fc-lines-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fcLines) {
remove();
return;
}
const segs: DashSeg[] = [];
for (const l of fcLinks || []) {
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
}
if (segs.length === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: segs.map((s, idx) => ({
type: 'Feature',
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
properties: {
type: 'fc',
suspicious: s.suspicious,
distanceNm: s.distanceNm,
fcMmsi: s.fromMmsi ?? -1,
otherMmsi: s.toMmsi ?? -1,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('FC lines source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
['boolean', ['get', 'suspicious'], false],
FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML,
] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
'line-opacity': 0.9,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('FC lines layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Fleet circles
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const fillSrcId = 'fleet-circles-ml-fill-src';
const layerId = 'fleet-circles-ml';
const fillLayerId = 'fleet-circles-ml-fill';
const remove = () => {
try {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
} catch {
// ignore
}
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
remove();
return;
}
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makeFleetCircleFeatureId(c.ownerKey),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'fleet',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
highlighted: 0,
},
};
}),
};
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
type: 'fleet-fill',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
highlighted: 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine);
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Fleet circles source setup failed:', e);
return;
}
try {
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
if (existingFill) existingFill.setData(fcFill);
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Fleet circles source setup failed:', e);
return;
}
if (!map.getLayer(fillLayerId)) {
try {
map.addLayer(
{
id: fillLayerId,
type: 'fill',
source: fillSrcId,
layout: { visibility: 'visible' },
paint: {
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles fill layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
} catch {
// ignore
}
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
'line-opacity': 0.85,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Pair range
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-range-ml-src';
const layerId = 'pair-range-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairRange) {
remove();
return;
}
const ranges: PairRangeCircle[] = [];
for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
ranges.push({
center,
radiusNm: Math.max(0.05, p.distanceNm / 2),
warn: p.warn,
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
});
}
if (ranges.length === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: ranges.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'pair-range',
warn: c.warn,
aMmsi: c.aMmsi,
bMmsi: c.bMmsi,
distanceNm: c.distanceNm,
highlighted: 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Pair range source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
['boolean', ['get', 'warn'], false],
PAIR_RANGE_WARN_ML,
PAIR_RANGE_NORMAL_ML,
] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
'line-opacity': 0.85,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Pair range layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Paint state updates for hover highlights
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updateGlobeOverlayPaintStates = useCallback(() => {
if (projection !== 'globe' || projectionBusyRef.current) return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
: false;
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr =
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr =
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
try {
if (map.getLayer('pair-lines-ml')) {
map.setPaintProperty(
'pair-lines-ml', 'line-color',
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-lines-ml', 'line-width',
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty(
'fc-lines-ml', 'line-color',
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'fc-lines-ml', 'line-width',
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer('pair-range-ml')) {
map.setPaintProperty(
'pair-range-ml', 'line-color',
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-range-ml', 'line-width',
['case', pairHighlightExpr, 1.6, 1.0] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer('fleet-circles-ml-fill')) {
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
}
if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
}
} catch {
// ignore
}
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
updateGlobeOverlayPaintStates();
return () => {
stop();
};
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
}

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

파일 보기

@ -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,196 @@
import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react';
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import type { BaseMapId, MapProjectionId } from '../types';
import { DECK_VIEW_ID } from '../constants';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { ensureSeamarkOverlay } from '../layers/seamark';
import { resolveMapStyle } from '../layers/bathymetry';
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
export function useMapInit(
containerRef: MutableRefObject<HTMLDivElement | null>,
mapRef: MutableRefObject<maplibregl.Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
baseMapRef: MutableRefObject<BaseMapId>,
projectionRef: MutableRefObject<MapProjectionId>,
opts: {
baseMap: BaseMapId;
projection: MapProjectionId;
showSeamark: boolean;
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
},
) {
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
const showSeamarkRef = useRef(showSeamark);
useEffect(() => {
showSeamarkRef.current = showSeamark;
}, [showSeamark]);
const ensureMercatorOverlay = useCallback(() => {
const map = mapRef.current;
if (!map) return null;
if (overlayRef.current) return overlayRef.current;
try {
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
map.addControl(next);
overlayRef.current = next;
return next;
} catch (e) {
console.warn('Deck overlay create failed:', e);
return null;
}
}, []);
const clearGlobeNativeLayersCb = useCallback(() => {
const map = mapRef.current;
if (!map) return;
clearGlobeNativeLayers(map);
}, []);
const pulseMapSync = useCallback(() => {
setMapSyncEpoch((prev) => prev + 1);
requestAnimationFrame(() => {
kickRepaint(mapRef.current);
setMapSyncEpoch((prev) => prev + 1);
});
}, [setMapSyncEpoch]);
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
let map: maplibregl.Map | null = null;
let cancelled = false;
const controller = new AbortController();
(async () => {
let style: string | StyleSpecification = '/map/styles/osm-seamark.json';
try {
style = await resolveMapStyle(baseMapRef.current, controller.signal);
} catch (e) {
console.warn('Map style init failed, falling back to local raster style:', e);
style = '/map/styles/osm-seamark.json';
}
if (cancelled || !containerRef.current) return;
map = new maplibregl.Map({
container: containerRef.current,
style,
center: [126.5, 34.2],
zoom: 7,
pitch: 45,
bearing: 0,
maxPitch: 85,
dragRotate: true,
pitchWithRotate: true,
touchPitch: true,
scrollZoom: { around: 'center' },
});
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
mapRef.current = map;
if (projectionRef.current === 'mercator') {
const overlay = ensureMercatorOverlay();
if (!overlay) return;
overlayRef.current = overlay;
} else {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: 'deck-globe',
viewId: DECK_VIEW_ID,
deckProps: { layers: [] },
});
}
function applyProjection() {
if (!map) return;
const next = projectionRef.current;
if (next === 'mercator') return;
try {
map.setProjection({ type: next });
map.setRenderWorldCopies(next !== 'globe');
} catch (e) {
console.warn('Projection apply failed:', e);
}
}
onMapStyleReady(map, () => {
applyProjection();
const deckLayer = globeDeckLayerRef.current;
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) {
try {
map!.addLayer(deckLayer);
} catch {
// ignore
}
}
if (!showSeamarkRef.current) return;
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
} catch {
// ignore
}
});
const emitBbox = () => {
const cb = onViewBboxChange;
if (!cb || !map) return;
const b = map.getBounds();
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
map.on('load', emitBbox);
map.on('moveend', emitBbox);
map.once('load', () => {
if (showSeamarkRef.current) {
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
} catch {
// ignore
}
try {
const opacity = showSeamarkRef.current ? 0.85 : 0;
map!.setPaintProperty('seamark', 'raster-opacity', opacity);
} catch {
// ignore
}
}
});
})();
return () => {
cancelled = true;
controller.abort();
try {
globeDeckLayerRef.current?.requestFinalize();
} catch {
// ignore
}
if (map) {
map.remove();
map = null;
}
if (overlayRef.current) {
overlayRef.current.finalize();
overlayRef.current = null;
}
if (overlayInteractionRef.current) {
overlayInteractionRef.current.finalize();
overlayInteractionRef.current = null;
}
globeDeckLayerRef.current = null;
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync };
}

파일 보기

@ -0,0 +1,210 @@
import { useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { destinationPointLngLat } from '../lib/geometry';
import { isFiniteNumber } from '../lib/setUtils';
import { toValidBearingDeg, lightenColor } from '../lib/shipUtils';
export function usePredictionVectors(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
settings: Map3DSettings;
shipData: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
selectedMmsi: number | null;
externalHighlightedSetRef: Set<number>;
projection: MapProjectionId;
baseMap: string;
mapSyncEpoch: number;
},
) {
const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'predict-vectors-src';
const outlineId = 'predict-vectors-outline';
const lineId = 'predict-vectors';
const hlOutlineId = 'predict-vectors-hl-outline';
const hlId = 'predict-vectors-hl';
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
const visibility = overlays.predictVectors ? 'visible' : 'none';
const horizonMinutes = 15;
const horizonSeconds = horizonMinutes * 60;
const metersPerSecondPerKnot = 0.514444;
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
for (const t of shipData) {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const isTarget = !!legacy;
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const sog = isFiniteNumber(t.sog) ? t.sog : null;
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
if (sog == null || bearing == null) continue;
if (sog < 0.2) continue;
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
if (!Number.isFinite(distM) || distM <= 0) continue;
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
const baseRgb = isTarget
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving
: OTHER_AIS_SPEED_RGB.moving;
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
const alpha = isTarget ? 0.72 : 0.52;
const alphaHl = isTarget ? 0.92 : 0.84;
const hl = isSelected || isPinnedHighlight ? 1 : 0;
features.push({
type: 'Feature',
id: `pred-${t.mmsi}`,
geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] },
properties: {
mmsi: t.mmsi,
minutes: horizonMinutes,
sog,
cog: bearing,
target: isTarget ? 1 : 0,
hl,
color: rgbaCss(rgb, alpha),
colorHl: rgbaCss(rgb, alphaHl),
},
});
}
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { type: 'FeatureCollection', features };
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Prediction vector source setup failed:', e);
return;
}
const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => {
if (!map.getLayer(id)) {
try {
map.addLayer(
{
id,
type: 'line',
source: srcId,
filter: filter as never,
layout: {
visibility,
'line-cap': 'round',
'line-join': 'round',
},
paint,
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Prediction vector layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(id, 'visibility', visibility);
map.setFilter(id, filter as never);
if (paint && typeof paint === 'object') {
for (const [key, value] of Object.entries(paint)) {
map.setPaintProperty(id, key as never, value as never);
}
}
} catch {
// ignore
}
}
};
const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[];
const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[];
ensureLayer(
outlineId,
{
'line-color': 'rgba(2,6,23,0.86)',
'line-width': 4.8,
'line-opacity': 1,
'line-blur': 0.2,
'line-dasharray': [1.2, 1.8] as never,
} as never,
baseFilter,
);
ensureLayer(
lineId,
{
'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never,
'line-width': 2.4,
'line-opacity': 1,
'line-dasharray': [1.2, 1.8] as never,
} as never,
baseFilter,
);
ensureLayer(
hlOutlineId,
{
'line-color': 'rgba(2,6,23,0.92)',
'line-width': 6.4,
'line-opacity': 1,
'line-blur': 0.25,
'line-dasharray': [1.2, 1.8] as never,
} as never,
hlFilter,
);
ensureLayer(
hlId,
{
'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never,
'line-width': 3.6,
'line-opacity': 1,
'line-dasharray': [1.2, 1.8] as never,
} as never,
hlFilter,
);
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
overlays.predictVectors,
settings.showShips,
shipData,
legacyHits,
selectedMmsi,
externalHighlightedSetRef,
projection,
baseMap,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
}

파일 보기

@ -0,0 +1,324 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import type { MapProjectionId } from '../types';
import { DECK_VIEW_ID } from '../constants';
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
import { removeLayerIfExists } from '../lib/layerHelpers';
export function useProjectionToggle(
mapRef: MutableRefObject<maplibregl.Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
projectionBusyRef: MutableRefObject<boolean>,
opts: {
projection: MapProjectionId;
clearGlobeNativeLayers: () => void;
ensureMercatorOverlay: () => MapboxOverlay | null;
onProjectionLoadingChange?: (loading: boolean) => void;
pulseMapSync: () => void;
setMapSyncEpoch: (updater: (prev: number) => number) => void;
},
): () => void {
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(projection);
const projectionRef = useRef<MapProjectionId>(projection);
useEffect(() => {
projectionRef.current = projection;
}, [projection]);
const clearProjectionBusyTimer = useCallback(() => {
if (projectionBusyTimerRef.current == null) return;
clearTimeout(projectionBusyTimerRef.current);
projectionBusyTimerRef.current = null;
}, []);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const endProjectionLoading = useCallback(() => {
if (!projectionBusyRef.current) return;
projectionBusyRef.current = false;
clearProjectionBusyTimer();
if (onProjectionLoadingChange) {
onProjectionLoadingChange(false);
}
setMapSyncEpoch((prev) => prev + 1);
kickRepaint(mapRef.current);
}, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]);
const setProjectionLoading = useCallback(
// eslint-disable-next-line react-hooks/preserve-manual-memoization
(loading: boolean) => {
if (projectionBusyRef.current === loading) return;
if (!loading) {
endProjectionLoading();
return;
}
clearProjectionBusyTimer();
projectionBusyRef.current = true;
const token = ++projectionBusyTokenRef.current;
if (onProjectionLoadingChange) {
onProjectionLoadingChange(true);
}
projectionBusyTimerRef.current = setTimeout(() => {
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
console.debug('Projection loading fallback timeout reached.');
endProjectionLoading();
}, 4000);
},
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
);
useEffect(() => {
return () => {
clearProjectionBusyTimer();
endProjectionLoading();
};
}, [clearProjectionBusyTimer, endProjectionLoading]);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const reorderGlobeFeatureLayers = useCallback(() => {
const map = mapRef.current;
if (!map || projectionRef.current !== 'globe') return;
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
const ordering = [
'zones-fill',
'zones-line',
'zones-label',
'predict-vectors-outline',
'predict-vectors',
'predict-vectors-hl-outline',
'predict-vectors-hl',
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml',
'fc-lines-ml',
'pair-range-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
];
for (const layerId of ordering) {
try {
if (map.getLayer(layerId)) map.moveLayer(layerId);
} catch {
// ignore
}
}
kickRepaint(map);
}, []);
// Projection toggle (mercator <-> globe)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
let retries = 0;
const maxRetries = 18;
const isTransition = projectionPrevRef.current !== projection;
projectionPrevRef.current = projection;
let settleScheduled = false;
let settleCleanup: (() => void) | null = null;
const startProjectionSettle = () => {
if (!isTransition || settleScheduled) return;
settleScheduled = true;
const finalize = () => {
if (!cancelled && isTransition) setProjectionLoading(false);
};
const finalizeSoon = () => {
if (cancelled || !isTransition || projectionBusyRef.current === false) return;
if (!map.isStyleLoaded()) {
requestAnimationFrame(finalizeSoon);
return;
}
requestAnimationFrame(finalize);
};
const onIdle = () => finalizeSoon();
try {
map.on('idle', onIdle);
const styleReadyCleanup = onMapStyleReady(map, finalizeSoon);
settleCleanup = () => {
map.off('idle', onIdle);
styleReadyCleanup();
};
} catch {
requestAnimationFrame(finalize);
settleCleanup = null;
}
finalizeSoon();
};
if (isTransition) setProjectionLoading(true);
const disposeMercatorOverlays = () => {
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
if (!target) return;
try {
target.setProps({ layers: [] } as never);
} catch {
// ignore
}
try {
map.removeControl(target as never);
} catch {
// ignore
}
try {
target.finalize();
} catch {
// ignore
}
if (toNull === 'base') {
overlayRef.current = null;
} else {
overlayInteractionRef.current = null;
}
};
disposeOne(overlayRef.current, 'base');
disposeOne(overlayInteractionRef.current, 'interaction');
};
const disposeGlobeDeckLayer = () => {
const current = globeDeckLayerRef.current;
if (!current) return;
removeLayerIfExists(map, current.id);
try {
current.requestFinalize();
} catch {
// ignore
}
globeDeckLayerRef.current = null;
};
const syncProjectionAndDeck = () => {
if (cancelled) return;
if (!isTransition) {
return;
}
if (!map.isStyleLoaded()) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
}
return;
}
const next = projection;
const currentProjection = extractProjectionType(map);
const shouldSwitchProjection = currentProjection !== next;
if (projection === 'globe') {
disposeMercatorOverlays();
clearGlobeNativeLayers();
} else {
disposeGlobeDeckLayer();
clearGlobeNativeLayers();
}
try {
if (shouldSwitchProjection) {
map.setProjection({ type: next });
}
map.setRenderWorldCopies(next !== 'globe');
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
} catch (e) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
if (isTransition) setProjectionLoading(false);
console.warn('Projection switch failed:', e);
}
if (projection === 'globe') {
disposeGlobeDeckLayer();
if (!globeDeckLayerRef.current) {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: 'deck-globe',
viewId: DECK_VIEW_ID,
deckProps: { layers: [] },
});
}
const layer = globeDeckLayerRef.current;
const layerId = layer?.id;
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
try {
map.addLayer(layer);
} catch {
// ignore
}
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
}
} else {
disposeGlobeDeckLayer();
ensureMercatorOverlay();
}
reorderGlobeFeatureLayers();
kickRepaint(map);
try {
map.resize();
} catch {
// ignore
}
if (isTransition) {
startProjectionSettle();
}
pulseMapSync();
};
if (!isTransition) return;
if (map.isStyleLoaded()) syncProjectionAndDeck();
else {
const stop = onMapStyleReady(map, syncProjectionAndDeck);
return () => {
cancelled = true;
if (settleCleanup) settleCleanup();
stop();
if (isTransition) setProjectionLoading(false);
};
}
return () => {
cancelled = true;
if (settleCleanup) settleCleanup();
if (isTransition) setProjectionLoading(false);
};
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
return reorderGlobeFeatureLayers;
}

파일 보기

@ -0,0 +1,229 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import maplibregl, { type LayerSpecification } from 'maplibre-gl';
import type { SubcableGeoJson } from '../../../entities/subcable/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { MapProjectionId } from '../types';
import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
const SRC_ID = 'subcables-src';
const LINE_ID = 'subcables-line';
const LINE_HOVER_ID = 'subcables-line-hover';
const LABEL_ID = 'subcables-label';
export function useSubcablesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
subcableGeo: SubcableGeoJson | null;
overlays: MapToggleState;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredCableId: string | null;
onHoverCable: (cableId: string | null) => void;
onClickCable: (cableId: string | null) => void;
},
) {
const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts;
const onHoverRef = useRef(onHoverCable);
const onClickRef = useRef(onClickCable);
onHoverRef.current = onHoverCable;
onClickRef.current = onClickCable;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const ensure = () => {
if (projectionBusyRef.current) return;
const visibility = overlays.subcables ? 'visible' : 'none';
setLayerVisibility(map, LINE_ID, overlays.subcables);
setLayerVisibility(map, LINE_HOVER_ID, overlays.subcables);
setLayerVisibility(map, LABEL_ID, overlays.subcables);
if (!subcableGeo) return;
if (!map.isStyleLoaded()) return;
try {
ensureGeoJsonSource(map, SRC_ID, subcableGeo);
const before = map.getLayer('zones-fill')
? 'zones-fill'
: map.getLayer('deck-globe')
? 'deck-globe'
: undefined;
ensureLayer(
map,
{
id: LINE_ID,
type: 'line',
source: SRC_ID,
paint: {
'line-color': ['get', 'color'],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7],
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8],
},
layout: { visibility, 'line-cap': 'round', 'line-join': 'round' },
} as unknown as LayerSpecification,
{ before },
);
ensureLayer(
map,
{
id: LINE_HOVER_ID,
type: 'line',
source: SRC_ID,
paint: {
'line-color': ['get', 'color'],
'line-opacity': 0,
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 8],
},
filter: ['==', ['get', 'id'], ''],
layout: { visibility, 'line-cap': 'round', 'line-join': 'round' },
} as unknown as LayerSpecification,
{ before },
);
ensureLayer(
map,
{
id: LABEL_ID,
type: 'symbol',
source: SRC_ID,
layout: {
visibility,
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-allow-overlap': false,
'text-padding': 8,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(210,225,240,0.78)',
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.0,
'text-halo-blur': 0.6,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.85],
},
minzoom: 4,
} as unknown as LayerSpecification,
);
// Update hover highlight
if (hoveredCableId) {
if (map.getLayer(LINE_ID)) {
map.setPaintProperty(LINE_ID, 'line-opacity', [
'case',
['==', ['get', 'id'], hoveredCableId],
0.95,
['interpolate', ['linear'], ['zoom'], 2, 0.25, 6, 0.35, 10, 0.45],
] as never);
map.setPaintProperty(LINE_ID, 'line-width', [
'case',
['==', ['get', 'id'], hoveredCableId],
['interpolate', ['linear'], ['zoom'], 2, 2.0, 6, 2.8, 10, 3.5],
['interpolate', ['linear'], ['zoom'], 2, 0.6, 6, 0.9, 10, 1.4],
] as never);
}
if (map.getLayer(LINE_HOVER_ID)) {
map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], hoveredCableId] as never);
map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0.25);
}
} else {
if (map.getLayer(LINE_ID)) {
map.setPaintProperty(
LINE_ID,
'line-opacity',
['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7] as never,
);
map.setPaintProperty(
LINE_ID,
'line-width',
['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8] as never,
);
}
if (map.getLayer(LINE_HOVER_ID)) {
map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], ''] as never);
map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0);
}
}
} catch (e) {
console.warn('Subcables layer setup failed:', e);
} finally {
reorderGlobeFeatureLayers();
kickRepaint(map);
}
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [subcableGeo, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]);
// Mouse events
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!overlays.subcables) return;
const onMouseEnter = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const cableId = e.features?.[0]?.properties?.id;
if (typeof cableId === 'string' && cableId) {
map.getCanvas().style.cursor = 'pointer';
onHoverRef.current(cableId);
}
};
const onMouseLeave = () => {
map.getCanvas().style.cursor = '';
onHoverRef.current(null);
};
const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const cableId = e.features?.[0]?.properties?.id;
if (typeof cableId === 'string' && cableId) {
onClickRef.current(cableId);
}
};
const addEvents = () => {
if (!map.getLayer(LINE_ID)) return;
map.on('mouseenter', LINE_ID, onMouseEnter);
map.on('mouseleave', LINE_ID, onMouseLeave);
map.on('click', LINE_ID, onClick);
};
if (map.isStyleLoaded() && map.getLayer(LINE_ID)) {
addEvents();
} else {
map.once('idle', addEvents);
}
return () => {
try {
map.off('mouseenter', LINE_ID, onMouseEnter);
map.off('mouseleave', LINE_ID, onMouseLeave);
map.off('click', LINE_ID, onClick);
} catch {
// ignore
}
};
}, [overlays.subcables, mapSyncEpoch]);
// Cleanup on unmount
useEffect(() => {
const mapInstance = mapRef.current;
return () => {
if (!mapInstance) return;
cleanupLayers(mapInstance, [LABEL_ID, LINE_HOVER_ID, LINE_ID], [SRC_ID]);
};
}, []);
}

파일 보기

@ -0,0 +1,230 @@
import { useEffect, type MutableRefObject } from 'react';
import maplibregl, {
type GeoJSONSource,
type GeoJSONSourceSpecification,
type LayerSpecification,
} from 'maplibre-gl';
import type { ZoneId } from '../../../entities/zone/model/meta';
import { ZONE_META } from '../../../entities/zone/model/meta';
import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { BaseMapId, MapProjectionId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
export function useZonesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
zones: ZonesGeoJson | null;
overlays: MapToggleState;
projection: MapProjectionId;
baseMap: BaseMapId;
hoveredZoneId: string | null;
mapSyncEpoch: number;
},
) {
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'zones-src';
const fillId = 'zones-fill';
const lineId = 'zones-line';
const labelId = 'zones-label';
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneColorExpr.push(k, ZONE_META[k].color);
}
zoneColorExpr.push('#3B82F6');
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneLabelExpr.push(k, ZONE_META[k].name);
}
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
const ensure = () => {
if (projectionBusyRef.current) return;
const visibility = overlays.zones ? 'visible' : 'none';
try {
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
} catch {
// ignore
}
try {
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
} catch {
// ignore
}
try {
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
} catch {
// ignore
}
if (!zones) return;
if (!map.isStyleLoaded()) return;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) {
existing.setData(zones);
} else {
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
}
const style = map.getStyle();
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
| { id?: string }
| undefined;
const before = map.getLayer('deck-globe')
? 'deck-globe'
: map.getLayer('ships')
? 'ships'
: map.getLayer('seamark')
? 'seamark'
: firstSymbol?.id;
const zoneMatchExpr =
hoveredZoneId !== null
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
: false;
const zoneLineWidthExpr = hoveredZoneId
? ([
'interpolate',
['linear'],
['zoom'],
4,
['case', zoneMatchExpr, 1.6, 0.8],
10,
['case', zoneMatchExpr, 2.0, 1.4],
14,
['case', zoneMatchExpr, 2.8, 2.1],
] as unknown as never)
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
if (map.getLayer(fillId)) {
try {
map.setPaintProperty(
fillId,
'fill-opacity',
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
);
} catch {
// ignore
}
}
if (map.getLayer(lineId)) {
try {
map.setPaintProperty(
lineId,
'line-color',
hoveredZoneId
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
: (zoneColorExpr as never),
);
} catch {
// ignore
}
try {
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
} catch {
// ignore
}
try {
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
} catch {
// ignore
}
}
if (!map.getLayer(fillId)) {
map.addLayer(
{
id: fillId,
type: 'fill',
source: srcId,
paint: {
'fill-color': zoneColorExpr as never,
'fill-opacity': hoveredZoneId
? ([
'case',
zoneMatchExpr,
0.24,
0.1,
] as unknown as number)
: 0.12,
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
if (!map.getLayer(lineId)) {
map.addLayer(
{
id: lineId,
type: 'line',
source: srcId,
paint: {
'line-color': hoveredZoneId
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
: (zoneColorExpr as never),
'line-opacity': hoveredZoneId
? (['case', zoneMatchExpr, 1, 0.85] as never)
: 0.85,
'line-width': zoneLineWidthExpr,
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
if (!map.getLayer(labelId)) {
map.addLayer(
{
id: labelId,
type: 'symbol',
source: srcId,
layout: {
visibility,
'symbol-placement': 'point',
'text-field': zoneLabelExpr as never,
'text-size': 11,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-anchor': 'top',
'text-offset': [0, 0.35],
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': '#dbeafe',
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
}
} catch (e) {
console.warn('Zones layer setup failed:', e);
} finally {
reorderGlobeFeatureLayers();
kickRepaint(map);
}
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
}

파일 보기

@ -0,0 +1,292 @@
import maplibregl, {
type LayerSpecification,
type StyleSpecification,
type VectorSourceSpecification,
} from 'maplibre-gl';
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] },
{ id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] },
{ id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] },
];
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = 'maptiler-ocean';
if (!style.sources) style.sources = {} as StyleSpecification['sources'];
if (!style.layers) style.layers = [];
if (!style.sources[oceanSourceId]) {
style.sources[oceanSourceId] = {
type: 'vector',
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
} satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string];
}
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
const bathyFillColor = [
'interpolate',
['linear'],
depth,
-11000,
'#00040b',
-8000,
'#010610',
-6000,
'#020816',
-4000,
'#030c1c',
-2000,
'#041022',
-1000,
'#051529',
-500,
'#061a30',
-200,
'#071f36',
-100,
'#08263d',
-50,
'#0e3d5e',
-20,
'#145578',
-10,
'#1a6e8e',
0,
'#2097a6',
] as const;
const bathyFill: LayerSpecification = {
id: 'bathymetry-fill',
type: 'fill',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 5,
maxzoom: 24,
paint: {
'fill-color': bathyFillColor,
'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78],
},
} as unknown as LayerSpecification;
const bathyBandBorders: LayerSpecification = {
id: 'bathymetry-borders',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 5,
maxzoom: 24,
paint: {
'line-color': 'rgba(255,255,255,0.06)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
},
} as unknown as LayerSpecification;
const bathyLinesMinor: LayerSpecification = {
id: 'bathymetry-lines',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
paint: {
'line-color': [
'interpolate',
['linear'],
depth,
-11000,
'rgba(255,255,255,0.04)',
-6000,
'rgba(255,255,255,0.05)',
-2000,
'rgba(255,255,255,0.07)',
0,
'rgba(255,255,255,0.10)',
],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
},
} as unknown as LayerSpecification;
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
const bathyMajorDepthFilter: unknown[] = [
'in',
['to-number', ['get', 'depth']],
['literal', majorDepths],
] as unknown[];
const bathyLinesMajor: LayerSpecification = {
id: 'bathymetry-lines-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
},
} as unknown as LayerSpecification;
const bathyBandBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
},
} as unknown as LayerSpecification;
const bathyLabels: LayerSpecification = {
id: 'bathymetry-labels',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 10,
filter: bathyMajorDepthFilter as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15],
'text-allow-overlap': false,
'text-padding': 2,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(226,232,240,0.72)',
'text-halo-color': 'rgba(2,6,23,0.82)',
'text-halo-width': 1.0,
'text-halo-blur': 0.6,
},
} as unknown as LayerSpecification;
const landformLabels: LayerSpecification = {
id: 'bathymetry-landforms',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'landform',
minzoom: 8,
filter: ['has', 'name'] as unknown as unknown[],
layout: {
'text-field': ['get', 'name'] as unknown as unknown[],
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13],
'text-allow-overlap': false,
'text-anchor': 'center',
'text-offset': [0, 0.0],
},
paint: {
'text-color': 'rgba(148,163,184,0.70)',
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.0,
'text-halo-blur': 0.7,
},
} as unknown as LayerSpecification;
const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : [];
if (!Array.isArray(style.layers)) {
style.layers = layers as unknown as StyleSpecification['layers'];
}
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
// with the bathymetry gradient instead of appearing as near-black voids.
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const SHALLOW_WATER_FILL = '#14606e';
const SHALLOW_WATER_LINE = '#114f5c';
for (const layer of layers) {
const id = getLayerId(layer);
if (!id) continue;
const spec = layer as Record<string, unknown>;
const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase();
const layerType = String(spec.type ?? '');
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
if (!isWater) continue;
const paint = (spec.paint ?? {}) as Record<string, unknown>;
if (layerType === 'fill') {
paint['fill-color'] = SHALLOW_WATER_FILL;
spec.paint = paint;
} else if (layerType === 'line') {
paint['line-color'] = SHALLOW_WATER_LINE;
spec.paint = paint;
}
}
const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol');
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
const existingIds = new Set<string>();
for (const layer of layers) {
const id = getLayerId(layer);
if (id) existingIds.add(id);
}
const toInsert = [
bathyFill,
bathyBandBorders,
bathyBandBordersMajor,
bathyLinesMinor,
bathyLinesMajor,
bathyLabels,
landformLabels,
].filter((l) => !existingIds.has(l.id));
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
}
export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) {
if (!map || !map.isStyleLoaded()) return;
if (baseMap !== 'enhanced') return;
const isGlobe = projection === 'globe';
for (const range of BATHY_ZOOM_RANGES) {
if (!map.getLayer(range.id)) continue;
const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator;
try {
map.setLayoutProperty(range.id, 'visibility', 'visible');
} catch {
// ignore
}
try {
map.setLayerZoomRange(range.id, minzoom, maxzoom);
} catch {
// ignore
}
}
}
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
const key = getMapTilerKey();
if (!key) return '/map/styles/osm-seamark.json';
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim();
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
const json = (await res.json()) as StyleSpecification;
injectOceanBathymetryLayers(json, key);
return json;
}
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
return resolveInitialMapStyle(signal);
}

파일 보기

@ -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,122 @@
import maplibregl, {
type GeoJSONSourceSpecification,
type LayerSpecification,
} from 'maplibre-gl';
export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) {
if (!layerId) return;
try {
if (map.getLayer(layerId)) {
map.removeLayer(layerId);
}
} catch {
// ignore
}
}
export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
try {
if (map.getSource(sourceId)) {
map.removeSource(sourceId);
}
} catch {
// ignore
}
}
const GLOBE_NATIVE_LAYER_IDS = [
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml',
'fc-lines-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
'pair-range-ml',
'subcables-line',
'subcables-line-hover',
'subcables-label',
'deck-globe',
];
const GLOBE_NATIVE_SOURCE_IDS = [
'ships-globe-src',
'ships-globe-hover-src',
'pair-lines-ml-src',
'fc-lines-ml-src',
'fleet-circles-ml-src',
'fleet-circles-ml-fill-src',
'pair-range-ml-src',
'subcables-src',
];
export function clearGlobeNativeLayers(map: maplibregl.Map) {
for (const id of GLOBE_NATIVE_LAYER_IDS) {
removeLayerIfExists(map, id);
}
for (const id of GLOBE_NATIVE_SOURCE_IDS) {
removeSourceIfExists(map, id);
}
}
export function ensureGeoJsonSource(
map: maplibregl.Map,
sourceId: string,
data: GeoJSON.GeoJSON,
) {
const existing = map.getSource(sourceId);
if (existing) {
(existing as maplibregl.GeoJSONSource).setData(data);
} else {
map.addSource(sourceId, {
type: 'geojson',
data,
} satisfies GeoJSONSourceSpecification);
}
}
export function ensureLayer(
map: maplibregl.Map,
spec: LayerSpecification,
options?: { before?: string },
) {
if (map.getLayer(spec.id)) return;
const before = options?.before && map.getLayer(options.before) ? options.before : undefined;
map.addLayer(spec, before);
}
export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) {
if (!map.getLayer(layerId)) return;
try {
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
} catch {
// ignore
}
}
export function cleanupLayers(
map: maplibregl.Map,
layerIds: string[],
sourceIds: string[],
) {
requestAnimationFrame(() => {
for (const id of layerIds) {
try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
}
for (const id of sourceIds) {
try {
if (map.getSource(id)) map.removeSource(id);
} catch {
// ignore
}
}
});
}

파일 보기

@ -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,68 @@
export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) {
return false;
}
const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[];
const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[];
return ['all', inA, inB] as unknown[];
}
export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) {
return false;
}
const literal = ['literal', hoveredMmsiList] as unknown[];
return [
'any',
['in', ['to-number', ['get', aField]], literal],
['in', ['to-number', ['get', bField]], literal],
] as unknown[];
}
export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) {
if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) {
return false;
}
const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[];
for (const ownerKey of hoveredOwnerKeys) {
expr.push(String(ownerKey), true);
}
expr.push(false);
return expr;
}
export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) {
return false;
}
const clauses = hoveredFleetMmsiList.map((mmsi) =>
['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[],
);
return ['any', ...clauses] as unknown[];
}
export function makeGlobeCircleRadiusExpr() {
const base3 = 4;
const base7 = 6;
const base10 = 8;
const base14 = 12;
const base18 = 32;
return [
'interpolate',
['linear'],
['zoom'],
3,
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
7,
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
10,
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
14,
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
18,
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
];
}
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;

파일 보기

@ -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,77 @@
import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
import type { SubcableGeoJson } from '../../entities/subcable/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;
subcableGeo?: SubcableGeoJson | null;
hoveredCableId?: string | null;
onHoverCable?: (cableId: string | null) => void;
onClickCable?: (cableId: string | null) => 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];
};

파일 보기

@ -0,0 +1,107 @@
import type { SubcableDetail } from '../../entities/subcable/model/types';
interface Props {
detail: SubcableDetail;
color?: string;
onClose: () => void;
}
export function SubcableInfoPanel({ detail, color, onClose }: Props) {
const landingCount = detail.landing_points.length;
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
return (
<div className="map-info" style={{ maxWidth: 340 }}>
<button className="close-btn" onClick={onClose} aria-label="close">
</button>
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{color && (
<div
style={{
width: 12,
height: 12,
borderRadius: 3,
backgroundColor: color,
flexShrink: 0,
}}
/>
)}
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div>
</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
</div>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv">{detail.length || '-'}</span>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv">{detail.rfs || '-'}</span>
</div>
{detail.owners && (
<div className="ir" style={{ alignItems: 'flex-start' }}>
<span className="il"></span>
<span className="iv" style={{ wordBreak: 'break-word' }}>
{detail.owners}
</span>
</div>
)}
{detail.suppliers && (
<div className="ir">
<span className="il"></span>
<span className="iv">{detail.suppliers}</span>
</div>
)}
{landingCount > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
Landing Points ({landingCount}) · {countries.length} countries
</div>
<div
style={{
maxHeight: 140,
overflowY: 'auto',
fontSize: 10,
lineHeight: 1.6,
color: 'var(--text)',
}}
>
{detail.landing_points.map((lp) => (
<div key={lp.id}>
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
<b>{lp.name}</b>
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
</div>
))}
</div>
</div>
)}
{detail.notes && (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
{detail.notes}
</div>
)}
{detail.url && (
<div style={{ marginTop: 8 }}>
<a
href={detail.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 10, color: 'var(--accent)' }}
>
Official website
</a>
</div>
)}
</div>
);
}

파일 보기

@ -10,7 +10,8 @@
"build:web": "npm -w @wing/web run build",
"build:api": "npm -w @wing/api run build",
"lint": "npm -w @wing/web run lint",
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs",
"prepare:subcables": "node scripts/prepare-subcables.mjs"
},
"devDependencies": {
"xlsx": "^0.18.5"

파일 보기

@ -0,0 +1,176 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const OUT_DIR = path.resolve(__dirname, "..", "apps", "web", "public", "data", "subcables");
const GEO_URL = "https://www.submarinecablemap.com/api/v3/cable/cable-geo.json";
const DETAILS_URL_BASE = "https://www.submarinecablemap.com/api/v3/cable/";
const CONCURRENCY = Math.max(1, Math.min(24, Number(process.env.CONCURRENCY || 12)));
const TIMEOUT_MS = Math.max(5_000, Math.min(60_000, Number(process.env.TIMEOUT_MS || 20_000)));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchText(url, { timeoutMs = TIMEOUT_MS } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
accept: "application/json",
},
});
const text = await res.text();
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
throw new Error(`HTTP ${res.status} (${res.statusText})`);
}
return { text, contentType };
} finally {
clearTimeout(timeout);
}
}
async function fetchJson(url) {
const { text, contentType } = await fetchText(url);
if (!contentType.toLowerCase().includes("application/json")) {
const snippet = text.slice(0, 200).replace(/\s+/g, " ").trim();
throw new Error(`Unexpected content-type (${contentType || "unknown"}): ${snippet || "<empty>"}`);
}
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
async function fetchJsonWithRetry(url, attempts = 2) {
let lastErr = null;
for (let i = 0; i < attempts; i += 1) {
try {
return await fetchJson(url);
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await sleep(250 * (i + 1));
}
}
}
throw lastErr;
}
function pickCableDetails(raw) {
const obj = raw && typeof raw === "object" ? raw : {};
const landingPoints = Array.isArray(obj.landing_points) ? obj.landing_points : [];
return {
id: String(obj.id || ""),
name: String(obj.name || ""),
length: obj.length == null ? null : String(obj.length),
rfs: obj.rfs == null ? null : String(obj.rfs),
rfs_year: typeof obj.rfs_year === "number" ? obj.rfs_year : null,
is_planned: Boolean(obj.is_planned),
owners: obj.owners == null ? null : String(obj.owners),
suppliers: obj.suppliers == null ? null : String(obj.suppliers),
landing_points: landingPoints.map((lp) => {
const p = lp && typeof lp === "object" ? lp : {};
return {
id: String(p.id || ""),
name: String(p.name || ""),
country: String(p.country || ""),
is_tbd: p.is_tbd === true,
};
}),
notes: obj.notes == null ? null : String(obj.notes),
url: obj.url == null ? null : String(obj.url),
};
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
console.log(`[subcables] fetching geojson: ${GEO_URL}`);
const geo = await fetchJsonWithRetry(GEO_URL, 3);
const geoPath = path.join(OUT_DIR, "cable-geo.json");
await fs.writeFile(geoPath, JSON.stringify(geo));
const features = Array.isArray(geo?.features) ? geo.features : [];
const ids = Array.from(
new Set(
features
.map((f) => f?.properties?.id)
.filter((v) => typeof v === "string" && v.trim().length > 0)
.map((v) => v.trim()),
),
).sort();
console.log(`[subcables] cables: ${ids.length} (concurrency=${CONCURRENCY}, timeoutMs=${TIMEOUT_MS})`);
const byId = {};
const failures = [];
let cursor = 0;
let completed = 0;
const startedAt = Date.now();
const worker = async () => {
for (;;) {
const idx = cursor;
cursor += 1;
if (idx >= ids.length) return;
const id = ids[idx];
const url = new URL(`${id}.json`, DETAILS_URL_BASE).toString();
try {
const raw = await fetchJsonWithRetry(url, 2);
const picked = pickCableDetails(raw);
if (!picked.id) {
throw new Error("Missing id in details response");
}
byId[id] = picked;
} catch (e) {
failures.push({ id, error: e instanceof Error ? e.message : String(e) });
} finally {
completed += 1;
if (completed % 25 === 0 || completed === ids.length) {
const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
const rate = (completed / sec).toFixed(1);
console.log(`[subcables] ${completed}/${ids.length} (${rate}/s)`);
}
}
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
const detailsOut = {
version: 1,
generated_at: new Date().toISOString(),
by_id: byId,
};
const detailsPath = path.join(OUT_DIR, "cable-details.min.json");
await fs.writeFile(detailsPath, JSON.stringify(detailsOut));
if (failures.length > 0) {
console.error(`[subcables] failures: ${failures.length}`);
for (const f of failures.slice(0, 30)) {
console.error(`- ${f.id}: ${f.error}`);
}
if (failures.length > 30) {
console.error(`- ... +${failures.length - 30} more`);
}
process.exitCode = 1;
}
console.log(`[subcables] wrote: ${geoPath}`);
console.log(`[subcables] wrote: ${detailsPath}`);
}
main().catch((e) => {
console.error(`[subcables] fatal: ${e instanceof Error ? e.stack || e.message : String(e)}`);
process.exitCode = 1;
});