From 51090aca2ac7d0ae84533e16ebc600091df0b97e Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 23:57:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor(map):=20Map3D=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=EB=B2=84=EA=B7=B8=204=EA=B1=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20+=20=EC=88=98=EC=8B=AC=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map3D.tsx 단일 파일(5752줄)에서 1200줄을 16개 모듈로 추출하여 탐색성과 유지보수성 개선. 모듈 구조: - types.ts, constants.ts: 타입/상수 정의 - lib/: setUtils, geometry, featureIds, mlExpressions, shipUtils, tooltips, globeShipIcon, mapCore, dashifyLine, layerHelpers, zoneUtils - layers/: bathymetry, seamark - hooks/: useHoverState 버그 수정: - fix: Globe 선박 라벨 미표시 (permitted boolean→number + filter 갱신) - fix: placement TypeError (isStyleLoaded 가드 + epoch change 시 remove 제거) - fix: Globe easeTo 미지원 경고 (globe 모드에서 flyTo 사용) - fix: 수심지도 얕은 구간 색상 미구분 (색상 팔레트 개선) 개선: - 베이스맵 water 레이어 색상을 수심 그라데이션과 자연스럽게 연결 - 프로젝션 전환 settle 로직 최적화 (더블프레임→싱글프레임) - glyphs URL 추가로 symbol 레이어 텍스트 렌더링 지원 Co-Authored-By: Claude Opus 4.6 --- apps/web/public/map/styles/carto-dark.json | 1 + apps/web/public/map/styles/osm-seamark.json | 1 + apps/web/src/widgets/map3d/Map3D.tsx | 1480 ++--------------- apps/web/src/widgets/map3d/constants.ts | 158 ++ .../src/widgets/map3d/hooks/useHoverState.ts | 66 + .../src/widgets/map3d/layers/bathymetry.ts | 292 ++++ apps/web/src/widgets/map3d/layers/seamark.ts | 27 + apps/web/src/widgets/map3d/lib/dashifyLine.ts | 31 + apps/web/src/widgets/map3d/lib/featureIds.ts | 19 + apps/web/src/widgets/map3d/lib/geometry.ts | 62 + .../src/widgets/map3d/lib/globeShipIcon.ts | 76 + .../web/src/widgets/map3d/lib/layerHelpers.ts | 62 + apps/web/src/widgets/map3d/lib/mapCore.ts | 124 ++ .../src/widgets/map3d/lib/mlExpressions.ts | 65 + apps/web/src/widgets/map3d/lib/setUtils.ts | 79 + apps/web/src/widgets/map3d/lib/shipUtils.ts | 117 ++ apps/web/src/widgets/map3d/lib/tooltips.ts | 169 ++ apps/web/src/widgets/map3d/lib/zoneUtils.ts | 40 + apps/web/src/widgets/map3d/types.ts | 72 + 19 files changed, 1604 insertions(+), 1337 deletions(-) create mode 100644 apps/web/src/widgets/map3d/constants.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useHoverState.ts create mode 100644 apps/web/src/widgets/map3d/layers/bathymetry.ts create mode 100644 apps/web/src/widgets/map3d/layers/seamark.ts create mode 100644 apps/web/src/widgets/map3d/lib/dashifyLine.ts create mode 100644 apps/web/src/widgets/map3d/lib/featureIds.ts create mode 100644 apps/web/src/widgets/map3d/lib/geometry.ts create mode 100644 apps/web/src/widgets/map3d/lib/globeShipIcon.ts create mode 100644 apps/web/src/widgets/map3d/lib/layerHelpers.ts create mode 100644 apps/web/src/widgets/map3d/lib/mapCore.ts create mode 100644 apps/web/src/widgets/map3d/lib/mlExpressions.ts create mode 100644 apps/web/src/widgets/map3d/lib/setUtils.ts create mode 100644 apps/web/src/widgets/map3d/lib/shipUtils.ts create mode 100644 apps/web/src/widgets/map3d/lib/tooltips.ts create mode 100644 apps/web/src/widgets/map3d/lib/zoneUtils.ts create mode 100644 apps/web/src/widgets/map3d/types.ts diff --git a/apps/web/public/map/styles/carto-dark.json b/apps/web/public/map/styles/carto-dark.json index f93fcb0..51aa7ba 100644 --- a/apps/web/public/map/styles/carto-dark.json +++ b/apps/web/public/map/styles/carto-dark.json @@ -1,6 +1,7 @@ { "version": 8, "name": "CARTO Dark (Legacy)", + "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "sources": { "carto-dark": { "type": "raster", diff --git a/apps/web/public/map/styles/osm-seamark.json b/apps/web/public/map/styles/osm-seamark.json index c6764e8..eacb8d8 100644 --- a/apps/web/public/map/styles/osm-seamark.json +++ b/apps/web/public/map/styles/osm-seamark.json @@ -1,6 +1,7 @@ { "version": 8, "name": "OSM Raster + OpenSeaMap", + "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "sources": { "osm": { "type": "raster", diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 6ca1b8d..1064450 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -7,1316 +7,117 @@ import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification, type StyleSpecification, - type VectorSourceSpecification, } from "maplibre-gl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; -import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; -import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; -import type { MapToggleState } from "../../features/mapToggles/MapToggles"; -import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; +import type { FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; +import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; +import type { BaseMapId, Map3DProps, MapProjectionId } from "./types"; +import type { DashSeg, PairRangeCircle } from "./types"; +import { + SHIP_ICON_MAPPING, + ANCHORED_SHIP_ICON_ID, + DEG2RAD, + GLOBE_ICON_HEADING_OFFSET_DEG, + 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, + DECK_VIEW_ID, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + LEGACY_CODE_COLORS, + 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, + 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 { + mergeNumberSets, + makeSetSignature, + isFiniteNumber, + toSafeNumber, + toIntMmsi, + makeUniqueSorted, + equalNumberArrays, +} from "./lib/setUtils"; +import { getZoneIdFromProps, getZoneDisplayNameFromProps } from "./lib/zoneUtils"; +import { + makePairLinkFeatureId, + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from "./lib/featureIds"; +import { + makeMmsiPairHighlightExpr, + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, + GLOBE_SHIP_CIRCLE_RADIUS_EXPR, +} from "./lib/mlExpressions"; +import { + toValidBearingDeg, + isAnchoredShip, + getDisplayHeading, + lightenColor, + getGlobeBaseShipColor, + getShipColor, +} from "./lib/shipUtils"; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from "./lib/tooltips"; +import { + buildFallbackGlobeAnchoredShipIcon, + ensureFallbackShipImage, +} from "./lib/globeShipIcon"; +import { kickRepaint, onMapStyleReady, extractProjectionType, getLayerId, sanitizeDeckLayerList } from "./lib/mapCore"; +import { destinationPointLngLat, circleRingLngLat, clampNumber } from "./lib/geometry"; +import { dashifyLine } from "./lib/dashifyLine"; +import { ensureSeamarkOverlay } from "./layers/seamark"; +import { applyBathymetryZoomProfile, resolveMapStyle } from "./layers/bathymetry"; +import { useHoverState } from "./hooks/useHoverState"; + +export type { Map3DSettings, BaseMapId, MapProjectionId } from "./types"; + +type Props = Map3DProps; -export type Map3DSettings = { - showSeamark: boolean; - showShips: boolean; - showDensity: boolean; -}; - -export type BaseMapId = "enhanced" | "legacy"; -export type MapProjectionId = "mercator" | "globe"; - -type Props = { - 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 | 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; -}; - -function toNumberSet(values: number[] | undefined | null) { - const out = new Set(); - if (!values) return out; - for (const value of values) { - if (Number.isFinite(value)) { - out.add(value); - } - } - return out; -} - -function mergeNumberSets(...sets: Set[]) { - const out = new Set(); - for (const s of sets) { - for (const v of s) { - out.add(v); - } - } - return out; -} - -function makeSetSignature(values: Set) { - return Array.from(values).sort((a, b) => a - b).join(","); -} - -function toTextValue(value: unknown): string { - if (value == null) return ""; - return String(value).trim(); -} - -function getZoneIdFromProps(props: Record | 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 ""; -} - -function getZoneDisplayNameFromProps(props: Record | 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}`; -} - -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}`; -} - -function makePairLinkFeatureId(a: number, b: number, suffix?: string) { - const pair = makeOrderedPairKey(a, b); - return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`; -} - -function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) { - const pair = makeOrderedPairKey(a, b); - return `fc-${pair}-${segmentIndex}`; -} - -function makeFleetCircleFeatureId(ownerKey: string) { - return `fleet-${ownerKey}`; -} - -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[]; -} - -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[]; -} - -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; -} - -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[]; -} - -const SHIP_ICON_MAPPING = { - ship: { - x: 0, - y: 0, - width: 128, - height: 128, - anchorX: 64, - anchorY: 64, - mask: true, - }, -} as const; - -const ANCHOR_SPEED_THRESHOLD_KN = 1; -const ANCHORED_SHIP_ICON_ID = "ship-globe-anchored-icon"; - -function isFiniteNumber(x: unknown): x is number { - return typeof x === "number" && Number.isFinite(x); -} - -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; -} - -function kickRepaint(map: maplibregl.Map | null) { - if (!map) return; - try { - map.triggerRepaint(); - } catch { - // ignore - } - try { - requestAnimationFrame(() => { - try { - map.triggerRepaint(); - } catch { - // ignore - } - }); - requestAnimationFrame(() => { - try { - map.triggerRepaint(); - } catch { - // ignore - } - }); - } catch { - // ignore (e.g., non-browser env) - } -} - -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 - } - }; -} - -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; -} - -const DEG2RAD = Math.PI / 180; -const RAD2DEG = 180 / Math.PI; -// ship.svg's native "up" direction is north (0deg), -// so map icon rotation can use COG directly. -const GLOBE_ICON_HEADING_OFFSET_DEG = 0; -const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; -const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; -const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; - -const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); - -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; -} - -function wrapLonDeg(lon: number) { - // Normalize longitude into [-180, 180). - const v = ((lon + 180) % 360 + 360) % 360; - return v - 180; -} - -function destinationPointLngLat( - from: [number, number], // [lon, lat] - 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]; -} - -function sanitizeDeckLayerList(value: unknown): unknown[] { - if (!Array.isArray(value)) return []; - const seen = new Set(); - 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; -} - -function normalizeAngleDeg(value: number, offset = 0): number { - const v = value + offset; - return ((v % 360) + 360) % 360; -} - -function toValidBearingDeg(value: unknown): number | null { - if (typeof value !== "number" || !Number.isFinite(value)) return null; - // AIS heading uses 511 as "not available". Some feeds may also use 360 as "not available". - if (value === 511) return null; - if (value < 0) return null; - if (value >= 360) return null; - return value; -} - -function getDisplayHeading({ - cog, - heading, - offset = 0, -}: { - cog: number | null | undefined; - heading: number | null | undefined; - offset?: number; -}) { - // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned. - const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; - return normalizeAngleDeg(raw, offset); -} - -function toSafeNumber(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) return value; - return null; -} - -function toIntMmsi(value: unknown): number | null { - const n = toSafeNumber(value); - if (n == null) return null; - return Math.trunc(n); -} - -function formatNm(value: number | null | undefined) { - if (!isFiniteNumber(value)) return "-"; - return `${value.toFixed(2)} NM`; -} - -function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { - const legacy = legacyHits?.get(mmsi); - if (!legacy) return null; - return `${legacy.permitNo} (${legacy.shipCode})`; -} - -function getTargetName(mmsi: number, targetByMmsi: Map, legacyHits: Map | null | undefined) { - const legacy = legacyHits?.get(mmsi); - const target = targetByMmsi.get(mmsi); - return ( - (target?.name || "").trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` - ); -} - -function getShipTooltipHtml({ - mmsi, - targetByMmsi, - legacyHits, -}: { - mmsi: number; - targetByMmsi: Map; - legacyHits: Map | 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 - ? `
-
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
-
유효범위: ${legacy.workSeaArea || "-"}
-
` - : ""; - - return { - html: `
-
${name}
-
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ""}
-
SOG: ${sog ?? "?"} kt · COG: ${cog ?? "?"}°
- ${msg ? `
${msg}
` : ""} - ${legacyHtml} -
`, - }; -} - -function getPairLinkTooltipHtml({ - warn, - distanceNm, - aMmsi, - bMmsi, - legacyHits, - targetByMmsi, -}: { - warn: boolean; - distanceNm: number | null | undefined; - aMmsi: number; - bMmsi: number; - legacyHits: Map | null | undefined; - targetByMmsi: Map; -}) { - 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: `
-
쌍 연결
-
${aTag ?? `MMSI ${aMmsi}`}
-
↔ ${bTag ?? `MMSI ${bMmsi}`}
-
거리: ${d} · 상태: ${warn ? "주의" : "정상"}
-
${a} / ${b}
-
`, - }; -} - -function getFcLinkTooltipHtml({ - suspicious, - distanceNm, - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi, -}: { - suspicious: boolean; - distanceNm: number | null | undefined; - fcMmsi: number; - otherMmsi: number; - legacyHits: Map | null | undefined; - targetByMmsi: Map; -}) { - 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: `
-
환적 연결
-
${aTag ?? `MMSI ${fcMmsi}`}
-
→ ${bTag ?? `MMSI ${otherMmsi}`}
-
거리: ${d} · 상태: ${suspicious ? "의심" : "일반"}
-
${a} / ${b}
-
`, - }; -} - -function getRangeTooltipHtml({ - warn, - distanceNm, - aMmsi, - bMmsi, - legacyHits, -}: { - warn: boolean; - distanceNm: number | null | undefined; - aMmsi: number; - bMmsi: number; - legacyHits: Map | null | undefined; -}) { - const d = formatNm(distanceNm); - const aTag = getLegacyTag(legacyHits, aMmsi); - const bTag = getLegacyTag(legacyHits, bMmsi); - const radiusNm = toSafeNumber(distanceNm); - return { - html: `
-
쌍 연결범위
-
${aTag ?? `MMSI ${aMmsi}`}
-
↔ ${bTag ?? `MMSI ${bMmsi}`}
-
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? "주의" : "정상"}
-
`, - }; -} - -function getFleetCircleTooltipHtml({ - ownerKey, - ownerLabel, - count, -}: { - ownerKey: string; - ownerLabel?: string; - count: number; -}) { - const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; - return { - html: `
-
선단 범위
-
소유주: ${displayOwner || "-"}
-
선박 수: ${count}
-
`, - }; -} - -function rgbToHex(rgb: [number, number, number]) { - const toHex = (v: number) => { - const clamped = Math.max(0, Math.min(255, Math.round(v))); - return clamped.toString(16).padStart(2, "0"); - }; - - return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; -} - -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; -} - -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)); - } - - // Non-target AIS should be visible but muted so target vessels stand out. - // Encode speed mostly via brightness (not hue) to avoid clashing with target category colors. - 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)"; -} - -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; - -// Deck.gl color constants (avoid per-object allocations inside accessors). -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, -]; -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, -]; -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, -]; -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, -]; -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, -]; -const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ - OVERLAY_SUSPICIOUS_RGB[0], - OVERLAY_SUSPICIOUS_RGB[1], - OVERLAY_SUSPICIOUS_RGB[2], - 220, -]; -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, -]; -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, -]; - -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, -]; -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, -]; -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, -]; -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, -]; -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, -]; -const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ - OVERLAY_SUSPICIOUS_RGB[0], - OVERLAY_SUSPICIOUS_RGB[1], - OVERLAY_SUSPICIOUS_RGB[2], - 245, -]; -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, -]; -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. -const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); -const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); -const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); -const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); - -const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); -const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); -const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); -const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); - -const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); -const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); -const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); -const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); - -const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); -const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); -const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); -const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); - -const DEPTH_DISABLED_PARAMS = { - // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. - // For 2D overlays like zones/icons/halos we want stable painter's-order rendering - // to avoid z-fighting flicker when layers overlap at (or near) the same z. - depthCompare: "always", - depthWriteEnabled: false, -} as const; - -const FLAT_SHIP_ICON_SIZE = 19; -const FLAT_SHIP_ICON_SIZE_SELECTED = 28; -const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; -const FLAT_LEGACY_HALO_RADIUS = 14; -const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; -const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; -const EMPTY_MMSI_SET = new Set(); - -const GLOBE_OVERLAY_PARAMS = { - // In globe mode we want depth-testing against the globe so features on the far side don't draw through. - // Still disable depth writes so our overlays don't interfere with each other. - depthCompare: "less-equal", - depthWriteEnabled: false, -} as const; - -function makeGlobeCircleRadiusExpr() { - const base3 = 4; - const base7 = 6; - const base10 = 8; - const base14 = 11; - - return [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["case", ["==", ["get", "selected"], 1], 4.6, ["==", ["get", "highlighted"], 1], 4.2, base3], - 7, - ["case", ["==", ["get", "selected"], 1], 6.8, ["==", ["get", "highlighted"], 1], 6.2, base7], - 10, - ["case", ["==", ["get", "selected"], 1], 9.0, ["==", ["get", "highlighted"], 1], 8.2, base10], - 14, - ["case", ["==", ["get", "selected"], 1], 11.8, ["==", ["get", "highlighted"], 1], 10.8, base14], - ]; -} - -const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; - -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); -} - -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); - - // Add a small anchor glyph below the ship body for anchored-state distinction. - 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); -} - -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 - } -} - -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; -} - -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; - - // By default, MapLibre adds new layers to the top. - // For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels. - const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined; - map.addLayer(layer, before); - } -} - -function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { - // NOTE: Vector-only bathymetry injection. - // Raster/DEM hillshade was intentionally removed for now because it caused ocean flicker - // and extra PNG tile traffic under globe projection in our setup. - 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[]; - - const bathyFillColor = [ - "interpolate", - ["linear"], - depth, - -11000, - "#00040b", - -8000, - "#010610", - -6000, - "#020816", - -4000, - "#030c1c", - -2000, - "#041022", - -1000, - "#051529", - -500, - "#061a30", - -200, - "#071f36", - -100, - "#08263d", - -50, - "#092c44", - -20, - "#0a334b", - 0, - "#0b3a53", - ] as const; - - const bathyFill: LayerSpecification = { - id: "bathymetry-fill", - type: "fill", - source: oceanSourceId, - "source-layer": "contour", - // Very low zoom tiles can contain extremely complex polygons (coastline/detail), - // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. - // We keep the fill starting at a more reasonable zoom. - minzoom: 6, - // Source maxzoom is 12, but we allow overzoom so the bathymetry doesn't disappear when zooming in. - maxzoom: 24, - paint: { - // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). - "fill-color": bathyFillColor, - "fill-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.9, 6, 0.86, 10, 0.78], - }, - } as unknown as LayerSpecification; - - - - const bathyBandBorders: LayerSpecification = { - id: "bathymetry-borders", - type: "line", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 6, - maxzoom: 24, - paint: { - "line-color": "rgba(255,255,255,0.06)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], - "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 0.2], - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.2, 8, 0.35, 12, 0.6], - }, - } as unknown as LayerSpecification; - - const bathyLinesMinor: LayerSpecification = { - id: "bathymetry-lines", - type: "line", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 8, - paint: { - "line-color": [ - "interpolate", - ["linear"], - depth, - -11000, - "rgba(255,255,255,0.04)", - -6000, - "rgba(255,255,255,0.05)", - -2000, - "rgba(255,255,255,0.07)", - 0, - "rgba(255,255,255,0.10)", - ], - "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.18, 10, 0.22, 12, 0.28], - "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 11, 0.3], - "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 10, 0.55, 12, 0.85], - }, - } as unknown as LayerSpecification; - - const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; - const bathyMajorDepthFilter: unknown[] = [ - "in", - ["to-number", ["get", "depth"]], - ["literal", majorDepths], - ] as unknown[]; - - const bathyLinesMajor: LayerSpecification = { - id: "bathymetry-lines-major", - type: "line", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 8, - maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], - paint: { - "line-color": "rgba(255,255,255,0.16)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34], - "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.4, 11, 0.2], - "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.6, 10, 0.95, 12, 1.3], - }, - } as unknown as LayerSpecification; - - const bathyBandBordersMajor: LayerSpecification = { - id: "bathymetry-borders-major", - type: "line", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 4, - maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], - paint: { - "line-color": "rgba(255,255,255,0.14)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26], - "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.3, 10, 0.15], - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.35, 8, 0.55, 12, 0.85], - }, - } as unknown as LayerSpecification; - - const bathyLabels: LayerSpecification = { - id: "bathymetry-labels", - type: "symbol", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 10, - filter: bathyMajorDepthFilter as unknown as unknown[], - layout: { - "symbol-placement": "line", - "text-field": depthLabel, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - // Make depth labels more legible on both mercator + globe. - "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; - - // Insert before the first symbol layer (keep labels on top), otherwise append. - 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"]; - } - - const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === "symbol"); - const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; - - const existingIds = new Set(); - 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); -} - -type BathyZoomRange = { - id: string; - mercator: [number, number]; - globe: [number, number]; -}; - -const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - // MapTiler Ocean tiles maxzoom=12; beyond that we overzoom the z12 geometry. - // Keep rendering at high zoom so the sea doesn't revert to the basemap's flat water color. - { 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] }, -]; - -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 { - // Safety: ensure heavy layers aren't stuck hidden from a previous session. - map.setLayoutProperty(range.id, "visibility", "visible"); - } catch { - // ignore - } - try { - map.setLayerZoomRange(range.id, minzoom, maxzoom); - } catch { - // ignore - } - } -} - -async function resolveInitialMapStyle(signal: AbortSignal): Promise { - 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; -} - -async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise { - if (baseMap === "legacy") return "/map/styles/carto-dark.json"; - return resolveInitialMapStyle(signal); -} - -function getShipColor( - t: AisTarget, - selectedMmsi: number | null, - legacyShipCode: string | null, - highlightedMmsis: Set, -): [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]; - } - // Non-target AIS: muted gray scale (avoid clashing with target category colors like PT/GN/etc). - 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]; // slate-400 - if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; // slate-500 - return [71, 85, 105, 165]; // slate-600 -} - -type DashSeg = { - from: [number, number]; - to: [number, number]; - suspicious: boolean; - distanceNm?: number; - fromMmsi?: number; - toMmsi?: number; -}; - -function dashifyLine( - from: [number, number], - to: [number, number], - suspicious: boolean, - distanceNm?: number, - fromMmsi?: number, - toMmsi?: number, -): DashSeg[] { - // Simple dashed effect: split into segments and render every other one. - 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; -} - -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; -} - -type PairRangeCircle = { - center: [number, number]; // [lon, lat] - radiusNm: number; - warn: boolean; - aMmsi: number; - bMmsi: number; - distanceNm: number; -}; - -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) as number[]; - } catch { - return []; - } - } - } - return []; -}; - -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; -}; - -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; -}; - -const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` -const DECK_VIEW_ID = "mapbox"; export function Map3D({ targets, @@ -1372,24 +173,20 @@ export function Map3D({ const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); - const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); - const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); - const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); - const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); - const [hoveredZoneId, setHoveredZoneId] = useState(null); - 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(); - if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); - if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); - return keys; - }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const { + setHoveredDeckMmsiSet, + setHoveredDeckPairMmsiSet, + setHoveredDeckFleetOwnerKey, + setHoveredDeckFleetMmsiSet, + hoveredZoneId, setHoveredZoneId, + hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, + externalHighlightedSetRef, + hoveredDeckMmsiSetRef, hoveredDeckPairMmsiSetRef, hoveredDeckFleetMmsiSetRef, + hoveredFleetOwnerKeys, + } = useHoverState({ + hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet, + hoveredFleetOwnerKey, highlightedMmsiSet, + }); const fleetFocusId = fleetFocus?.id; const fleetFocusLon = fleetFocus?.center?.[0]; const fleetFocusLat = fleetFocus?.center?.[1]; @@ -1399,6 +196,7 @@ export function Map3D({ const map = mapRef.current; if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; const ordering = [ "zones-fill", @@ -1717,10 +515,7 @@ export function Map3D({ // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately // without requiring a user toggle (e.g., industry filter). setMapSyncEpoch((prev) => prev + 1); - requestAnimationFrame(() => { - kickRepaint(mapRef.current); - setMapSyncEpoch((prev) => prev + 1); - }); + kickRepaint(mapRef.current); }, [clearProjectionBusyTimer, onProjectionLoadingChange]); const setProjectionLoading = useCallback( @@ -2022,7 +817,7 @@ export function Map3D({ requestAnimationFrame(finalizeSoon); return; } - requestAnimationFrame(() => requestAnimationFrame(finalize)); + requestAnimationFrame(finalize); }; const onIdle = () => finalizeSoon(); @@ -2956,7 +1751,6 @@ export function Map3D({ } if (globeShipsEpochRef.current !== mapSyncEpoch) { - remove(); globeShipsEpochRef.current = mapSyncEpoch; } @@ -3021,7 +1815,7 @@ export function Map3D({ sizeScale, selected: selected ? 1 : 0, highlighted: highlighted ? 1 : 0, - permitted: !!legacy, + permitted: legacy ? 1 : 0, code: legacy?.shipCode || "", }, }; @@ -3405,6 +2199,8 @@ export function Map3D({ } else { try { map.setLayoutProperty(labelId, "visibility", labelVisibility); + map.setFilter(labelId, labelFilter as never); + map.setLayoutProperty(labelId, "text-field", ["get", "labelName"] as never); } catch { // ignore } @@ -3470,7 +2266,6 @@ export function Map3D({ } if (globeShipsEpochRef.current !== mapSyncEpoch) { - remove(); globeShipsEpochRef.current = mapSyncEpoch; } @@ -3531,7 +2326,7 @@ export function Map3D({ iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), selected: selected ? 1 : 0, - permitted: !!legacy, + permitted: legacy ? 1 : 0, }, }; }), @@ -5412,7 +4207,12 @@ export function Map3D({ return; } onSelectMmsi(t.mmsi); - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + 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); + } } }, }; @@ -5718,7 +4518,12 @@ export function Map3D({ if (!selectedMmsi) return; const t = shipData.find((x) => x.mmsi === selectedMmsi); if (!t) return; - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + const opts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === "globe") { + map.flyTo(opts); + } else { + map.easeTo(opts); + } }, [selectedMmsi, shipData]); useEffect(() => { @@ -5730,11 +4535,12 @@ export function Map3D({ const zoom = fleetFocusZoom ?? 10; const apply = () => { - map.easeTo({ - center: [lon, lat], - zoom, - duration: 700, - }); + const opts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; + if (projectionRef.current === "globe") { + map.flyTo(opts); + } else { + map.easeTo(opts); + } }; if (map.isStyleLoaded()) { diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts new file mode 100644 index 0000000..05a2940 --- /dev/null +++ b/apps/web/src/widgets/map3d/constants.ts @@ -0,0 +1,158 @@ +import { + LEGACY_CODE_COLORS_RGB, + OVERLAY_RGB, + rgba as rgbaCss, +} from '../../shared/lib/map/palette'; +import type { BathyZoomRange } from './types'; + +// ── Re-export palette aliases used throughout Map3D ── + +export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; + +const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; +const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; +const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; +const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; +const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; + +// ── Ship icon mapping (Deck.gl IconLayer) ── + +export const SHIP_ICON_MAPPING = { + ship: { + x: 0, + y: 0, + width: 128, + height: 128, + anchorX: 64, + anchorY: 64, + mask: true, + }, +} as const; + +// ── Ship constants ── + +export const ANCHOR_SPEED_THRESHOLD_KN = 1; +export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon'; + +// ── Geometry constants ── + +export const DEG2RAD = Math.PI / 180; +export const RAD2DEG = 180 / Math.PI; +export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` +export const GLOBE_ICON_HEADING_OFFSET_DEG = 0; + +// ── Ship color constants ── + +export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; +export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; +export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; + +// ── Flat map icon sizes ── + +export const FLAT_SHIP_ICON_SIZE = 19; +export const FLAT_SHIP_ICON_SIZE_SELECTED = 28; +export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; +export const FLAT_LEGACY_HALO_RADIUS = 14; +export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; +export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; +export const EMPTY_MMSI_SET = new Set(); + +// ── Deck.gl view ID ── + +export const DECK_VIEW_ID = 'mapbox'; + +// ── Depth params ── + +export const DEPTH_DISABLED_PARAMS = { + depthCompare: 'always', + depthWriteEnabled: false, +} as const; + +export const GLOBE_OVERLAY_PARAMS = { + depthCompare: 'less-equal', + depthWriteEnabled: false, +} as const; + +// ── Deck.gl color constants (avoid per-object allocations inside accessors) ── + +export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110, +]; +export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170, +]; +export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85, +]; +export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220, +]; +export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200, +]; +export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220, +]; +export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140, +]; +export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6, +]; + +// ── Highlighted variants ── + +export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200, +]; +export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240, +]; +export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245, +]; +export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245, +]; +export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235, +]; +export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245, +]; +export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220, +]; +export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42, +]; + +// ── MapLibre overlay colors ── + +export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); +export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); +export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); +export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); + +export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); +export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); +export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); +export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); + +export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); +export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); +export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); +export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); + +export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); +export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); +export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); +export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); + +// ── Bathymetry zoom ranges ── + +export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, +]; diff --git a/apps/web/src/widgets/map3d/hooks/useHoverState.ts b/apps/web/src/widgets/map3d/hooks/useHoverState.ts new file mode 100644 index 0000000..a1cf7a0 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useHoverState.ts @@ -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([]); + const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); + const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); + const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); + const [hoveredZoneId, setHoveredZoneId] = useState(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(); + 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, + }; +} diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts new file mode 100644 index 0000000..e0f7004 --- /dev/null +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -0,0 +1,292 @@ +import maplibregl, { + type LayerSpecification, + type StyleSpecification, + type VectorSourceSpecification, +} from 'maplibre-gl'; +import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; +import { getLayerId, getMapTilerKey } from '../lib/mapCore'; + +const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, +]; + +export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { + const oceanSourceId = 'maptiler-ocean'; + + if (!style.sources) style.sources = {} as StyleSpecification['sources']; + if (!style.layers) style.layers = []; + + if (!style.sources[oceanSourceId]) { + style.sources[oceanSourceId] = { + type: 'vector', + url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`, + } satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string]; + } + + const depth = ['to-number', ['get', 'depth']] as unknown as number[]; + const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; + + // Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean + const bathyFillColor = [ + 'interpolate', + ['linear'], + depth, + -11000, + '#00040b', + -8000, + '#010610', + -6000, + '#020816', + -4000, + '#030c1c', + -2000, + '#041022', + -1000, + '#051529', + -500, + '#061a30', + -200, + '#071f36', + -100, + '#08263d', + -50, + '#0e3d5e', + -20, + '#145578', + -10, + '#1a6e8e', + 0, + '#2097a6', + ] as const; + + const bathyFill: LayerSpecification = { + id: 'bathymetry-fill', + type: 'fill', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 6, + maxzoom: 24, + paint: { + 'fill-color': bathyFillColor, + 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 6, 0.86, 10, 0.78], + }, + } as unknown as LayerSpecification; + + const bathyBandBorders: LayerSpecification = { + id: 'bathymetry-borders', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 6, + maxzoom: 24, + paint: { + 'line-color': 'rgba(255,255,255,0.06)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6], + }, + } as unknown as LayerSpecification; + + const bathyLinesMinor: LayerSpecification = { + id: 'bathymetry-lines', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 8, + paint: { + 'line-color': [ + 'interpolate', + ['linear'], + depth, + -11000, + 'rgba(255,255,255,0.04)', + -6000, + 'rgba(255,255,255,0.05)', + -2000, + 'rgba(255,255,255,0.07)', + 0, + 'rgba(255,255,255,0.10)', + ], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3], + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85], + }, + } as unknown as LayerSpecification; + + const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; + const bathyMajorDepthFilter: unknown[] = [ + 'in', + ['to-number', ['get', 'depth']], + ['literal', majorDepths], + ] as unknown[]; + + const bathyLinesMajor: LayerSpecification = { + id: 'bathymetry-lines-major', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 8, + maxzoom: 24, + filter: bathyMajorDepthFilter as unknown as unknown[], + paint: { + 'line-color': 'rgba(255,255,255,0.16)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3], + }, + } as unknown as LayerSpecification; + + const bathyBandBordersMajor: LayerSpecification = { + id: 'bathymetry-borders-major', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 4, + maxzoom: 24, + filter: bathyMajorDepthFilter as unknown as unknown[], + paint: { + 'line-color': 'rgba(255,255,255,0.14)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.14, 8, 0.2, 12, 0.26], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.3, 10, 0.15], + 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.35, 8, 0.55, 12, 0.85], + }, + } as unknown as LayerSpecification; + + const bathyLabels: LayerSpecification = { + id: 'bathymetry-labels', + type: 'symbol', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 10, + filter: bathyMajorDepthFilter as unknown as unknown[], + layout: { + 'symbol-placement': 'line', + 'text-field': depthLabel, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15], + 'text-allow-overlap': false, + 'text-padding': 2, + 'text-rotation-alignment': 'map', + }, + paint: { + 'text-color': 'rgba(226,232,240,0.72)', + 'text-halo-color': 'rgba(2,6,23,0.82)', + 'text-halo-width': 1.0, + 'text-halo-blur': 0.6, + }, + } as unknown as LayerSpecification; + + const landformLabels: LayerSpecification = { + id: 'bathymetry-landforms', + type: 'symbol', + source: oceanSourceId, + 'source-layer': 'landform', + minzoom: 8, + filter: ['has', 'name'] as unknown as unknown[], + layout: { + 'text-field': ['get', 'name'] as unknown as unknown[], + 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13], + 'text-allow-overlap': false, + 'text-anchor': 'center', + 'text-offset': [0, 0.0], + }, + paint: { + 'text-color': 'rgba(148,163,184,0.70)', + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.0, + 'text-halo-blur': 0.7, + }, + } as unknown as LayerSpecification; + + const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; + if (!Array.isArray(style.layers)) { + style.layers = layers as unknown as StyleSpecification['layers']; + } + + // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally + // with the bathymetry gradient instead of appearing as near-black voids. + const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + const SHALLOW_WATER_FILL = '#14606e'; + const SHALLOW_WATER_LINE = '#114f5c'; + for (const layer of layers) { + const id = getLayerId(layer); + if (!id) continue; + const spec = layer as Record; + 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; + 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(); + 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 { + 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 { + if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; + return resolveInitialMapStyle(signal); +} diff --git a/apps/web/src/widgets/map3d/layers/seamark.ts b/apps/web/src/widgets/map3d/layers/seamark.ts new file mode 100644 index 0000000..d5aa8e5 --- /dev/null +++ b/apps/web/src/widgets/map3d/layers/seamark.ts @@ -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); + } +} diff --git a/apps/web/src/widgets/map3d/lib/dashifyLine.ts b/apps/web/src/widgets/map3d/lib/dashifyLine.ts new file mode 100644 index 0000000..8f9099c --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/dashifyLine.ts @@ -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; +} diff --git a/apps/web/src/widgets/map3d/lib/featureIds.ts b/apps/web/src/widgets/map3d/lib/featureIds.ts new file mode 100644 index 0000000..54e8173 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/featureIds.ts @@ -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}`; +} diff --git a/apps/web/src/widgets/map3d/lib/geometry.ts b/apps/web/src/widgets/map3d/lib/geometry.ts new file mode 100644 index 0000000..6e8f5eb --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/geometry.ts @@ -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; +} diff --git a/apps/web/src/widgets/map3d/lib/globeShipIcon.ts b/apps/web/src/widgets/map3d/lib/globeShipIcon.ts new file mode 100644 index 0000000..718e4fb --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/globeShipIcon.ts @@ -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 + } +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts new file mode 100644 index 0000000..7eecc45 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -0,0 +1,62 @@ +import maplibregl, { + type GeoJSONSourceSpecification, + type LayerSpecification, +} from 'maplibre-gl'; + +export function ensureGeoJsonSource( + map: maplibregl.Map, + sourceId: string, + data: GeoJSON.GeoJSON, +) { + const existing = map.getSource(sourceId); + if (existing) { + (existing as maplibregl.GeoJSONSource).setData(data); + } else { + map.addSource(sourceId, { + type: 'geojson', + data, + } satisfies GeoJSONSourceSpecification); + } +} + +export function ensureLayer( + map: maplibregl.Map, + spec: LayerSpecification, + options?: { before?: string }, +) { + if (map.getLayer(spec.id)) return; + const before = options?.before && map.getLayer(options.before) ? options.before : undefined; + map.addLayer(spec, before); +} + +export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) { + if (!map.getLayer(layerId)) return; + try { + map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none'); + } catch { + // ignore + } +} + +export function cleanupLayers( + map: maplibregl.Map, + layerIds: string[], + sourceIds: string[], +) { + requestAnimationFrame(() => { + for (const id of layerIds) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + for (const id of sourceIds) { + try { + if (map.getSource(id)) map.removeSource(id); + } catch { + // ignore + } + } + }); +} diff --git a/apps/web/src/widgets/map3d/lib/mapCore.ts b/apps/web/src/widgets/map3d/lib/mapCore.ts new file mode 100644 index 0000000..9dca30e --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/mapCore.ts @@ -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(); + 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; +} diff --git a/apps/web/src/widgets/map3d/lib/mlExpressions.ts b/apps/web/src/widgets/map3d/lib/mlExpressions.ts new file mode 100644 index 0000000..7d9ede2 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/mlExpressions.ts @@ -0,0 +1,65 @@ +export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { + return false; + } + const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[]; + const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[]; + return ['all', inA, inB] as unknown[]; +} + +export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { + return false; + } + const literal = ['literal', hoveredMmsiList] as unknown[]; + return [ + 'any', + ['in', ['to-number', ['get', aField]], literal], + ['in', ['to-number', ['get', bField]], literal], + ] as unknown[]; +} + +export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { + if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { + return false; + } + const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[]; + for (const ownerKey of hoveredOwnerKeys) { + expr.push(String(ownerKey), true); + } + expr.push(false); + return expr; +} + +export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { + if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { + return false; + } + const clauses = hoveredFleetMmsiList.map((mmsi) => + ['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[], + ); + return ['any', ...clauses] as unknown[]; +} + +export function makeGlobeCircleRadiusExpr() { + const base3 = 4; + const base7 = 6; + const base10 = 8; + const base14 = 11; + + return [ + 'interpolate', + ['linear'], + ['zoom'], + 3, + ['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3], + 7, + ['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7], + 10, + ['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10], + 14, + ['case', ['==', ['get', 'selected'], 1], 11.8, ['==', ['get', 'highlighted'], 1], 10.8, base14], + ]; +} + +export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; diff --git a/apps/web/src/widgets/map3d/lib/setUtils.ts b/apps/web/src/widgets/map3d/lib/setUtils.ts new file mode 100644 index 0000000..fa9b79d --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/setUtils.ts @@ -0,0 +1,79 @@ +export function toNumberSet(values: number[] | undefined | null) { + const out = new Set(); + if (!values) return out; + for (const value of values) { + if (Number.isFinite(value)) { + out.add(value); + } + } + return out; +} + +export function mergeNumberSets(...sets: Set[]) { + const out = new Set(); + for (const s of sets) { + for (const v of s) { + out.add(v); + } + } + return out; +} + +export function makeSetSignature(values: Set) { + 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) 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; +}; diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts new file mode 100644 index 0000000..7e4fc59 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -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] { + 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, + 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})` : '', + }; +} diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts new file mode 100644 index 0000000..fb06a29 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -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 | 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, + legacyHits: Map | 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; + legacyHits: Map | 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 + ? `
+
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
+
유효범위: ${legacy.workSeaArea || '-'}
+
` + : ''; + + return { + html: `
+
${name}
+
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ''}
+
SOG: ${sog ?? '?'} kt · COG: ${cog ?? '?'}°
+ ${msg ? `
${msg}
` : ''} + ${legacyHtml} +
`, + }; +} + +export function getPairLinkTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, + targetByMmsi, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + 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: `
+
쌍 연결
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
거리: ${d} · 상태: ${warn ? '주의' : '정상'}
+
${a} / ${b}
+
`, + }; +} + +export function getFcLinkTooltipHtml({ + suspicious, + distanceNm, + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi, +}: { + suspicious: boolean; + distanceNm: number | null | undefined; + fcMmsi: number; + otherMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + 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: `
+
환적 연결
+
${aTag ?? `MMSI ${fcMmsi}`}
+
→ ${bTag ?? `MMSI ${otherMmsi}`}
+
거리: ${d} · 상태: ${suspicious ? '의심' : '일반'}
+
${a} / ${b}
+
`, + }; +} + +export function getRangeTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; +}) { + const d = formatNm(distanceNm); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + const radiusNm = toSafeNumber(distanceNm); + return { + html: `
+
쌍 연결범위
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? '주의' : '정상'}
+
`, + }; +} + +export function getFleetCircleTooltipHtml({ + ownerKey, + ownerLabel, + count, +}: { + ownerKey: string; + ownerLabel?: string; + count: number; +}) { + const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; + return { + html: `
+
선단 범위
+
소유주: ${displayOwner || '-'}
+
선박 수: ${count}
+
`, + }; +} diff --git a/apps/web/src/widgets/map3d/lib/zoneUtils.ts b/apps/web/src/widgets/map3d/lib/zoneUtils.ts new file mode 100644 index 0000000..086147c --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/zoneUtils.ts @@ -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 | 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 | 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}`; +} diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts new file mode 100644 index 0000000..b884e4a --- /dev/null +++ b/apps/web/src/widgets/map3d/types.ts @@ -0,0 +1,72 @@ +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; +import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; +import type { MapToggleState } from '../../features/mapToggles/MapToggles'; +import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; + +export type Map3DSettings = { + showSeamark: boolean; + showShips: boolean; + showDensity: boolean; +}; + +export type BaseMapId = 'enhanced' | 'legacy'; +export type MapProjectionId = 'mercator' | 'globe'; + +export interface Map3DProps { + targets: AisTarget[]; + zones: ZonesGeoJson | null; + selectedMmsi: number | null; + hoveredMmsiSet?: number[]; + hoveredFleetMmsiSet?: number[]; + hoveredPairMmsiSet?: number[]; + hoveredFleetOwnerKey?: string | null; + highlightedMmsiSet?: number[]; + settings: Map3DSettings; + baseMap: BaseMapId; + projection: MapProjectionId; + overlays: MapToggleState; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + onViewBboxChange?: (bbox: [number, number, number, number]) => void; + legacyHits?: Map | null; + pairLinks?: PairLink[]; + fcLinks?: FcLink[]; + fleetCircles?: FleetCircle[]; + onProjectionLoadingChange?: (loading: boolean) => void; + fleetFocus?: { + id: string | number; + center: [number, number]; + zoom?: number; + }; + onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; + onClearFleetHover?: () => void; + onHoverMmsi?: (mmsiList: number[]) => void; + onClearMmsiHover?: () => void; + onHoverPair?: (mmsiList: number[]) => void; + onClearPairHover?: () => void; +} + +export type DashSeg = { + from: [number, number]; + to: [number, number]; + suspicious: boolean; + distanceNm?: number; + fromMmsi?: number; + toMmsi?: number; +}; + +export type PairRangeCircle = { + center: [number, number]; // [lon, lat] + radiusNm: number; + warn: boolean; + aMmsi: number; + bMmsi: number; + distanceNm: number; +}; + +export type BathyZoomRange = { + id: string; + mercator: [number, number]; + globe: [number, number]; +};