From 69775c90a232a1c748d03a5318f36dbbd3614533 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 18:19:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=ED=95=AD=EC=A0=81=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20+=20SVG=20=EC=BA=90=EC=8B=9C=20+=20fitBounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (6h~5d) - Mercator: PathLayer(고정) + TripsLayer(애니메이션) + ScatterplotLayer(포인트) - Globe: MapLibre 네이티브 line + arrow + circle 레이어 - rAF 직접 overlay 조작으로 React 재렌더링 방지 - SVG 아이콘 data URL 캐시로 네트워크 재요청 방지 - 항적 조회 시 자동 fitBounds (전체 항적 뷰포트 맞춤) - API 프록시 /api/ais-target/:mmsi/track 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/index.ts | 46 + apps/web/package.json | 1 + .../entities/vesselTrack/api/fetchTrack.ts | 32 + .../vesselTrack/lib/buildTrackGeoJson.ts | 115 +++ .../src/entities/vesselTrack/model/types.ts | 39 + .../web/src/pages/dashboard/DashboardPage.tsx | 32 +- apps/web/src/widgets/map3d/Map3D.tsx | 80 +- .../map3d/components/VesselContextMenu.tsx | 135 +++ .../src/widgets/map3d/hooks/useDeckLayers.ts | 9 +- .../map3d/hooks/useMapStyleSettings.ts | 1 + .../map3d/hooks/useProjectionToggle.ts | 5 + .../widgets/map3d/hooks/useSubcablesLayer.ts | 65 +- .../map3d/hooks/useVesselTrackLayer.ts | 395 ++++++++ .../web/src/widgets/map3d/lib/layerHelpers.ts | 7 + .../src/widgets/map3d/lib/shipIconCache.ts | 30 + apps/web/src/widgets/map3d/lib/tooltips.ts | 51 ++ apps/web/src/widgets/map3d/types.ts | 6 + .../subcableInfo/SubcableInfoPanel.tsx | 156 +++- package-lock.json | 854 +++++++++++++++++- 19 files changed, 1976 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/entities/vesselTrack/api/fetchTrack.ts create mode 100644 apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts create mode 100644 apps/web/src/entities/vesselTrack/model/types.ts create mode 100644 apps/web/src/widgets/map3d/components/VesselContextMenu.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts create mode 100644 apps/web/src/widgets/map3d/lib/shipIconCache.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bb571f7..f141ab8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) { return { lonMin, latMin, lonMax, latMax }; } +app.get<{ + Params: { mmsi: string }; + Querystring: { minutes?: string }; +}>("/api/ais-target/:mmsi/track", async (req, reply) => { + const mmsiRaw = req.params.mmsi; + const mmsi = Number(mmsiRaw); + if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) { + return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" }); + } + + const minutesRaw = req.query.minutes ?? "360"; + const minutes = Number(minutesRaw); + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) { + return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" }); + } + + const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE); + u.searchParams.set("minutes", String(minutes)); + + const controller = new AbortController(); + const timeoutMs = 20_000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); + const txt = await res.text(); + if (!res.ok) { + req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error"); + return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); + } + + reply.type("application/json").send(txt); + } catch (e) { + const name = e instanceof Error ? e.name : ""; + const isTimeout = name === "AbortError"; + req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed"); + return reply.code(isTimeout ? 504 : 502).send({ + success: false, + message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", + data: [], + errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", + }); + } finally { + clearTimeout(timeout); + } +}); + app.get("/zones", async (_req, reply) => { const zonesPath = path.resolve( process.cwd(), diff --git a/apps/web/package.json b/apps/web/package.json index 39bbdea..a28712e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@react-oauth/google": "^0.13.4", diff --git a/apps/web/src/entities/vesselTrack/api/fetchTrack.ts b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts new file mode 100644 index 0000000..910b203 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts @@ -0,0 +1,32 @@ +import type { TrackResponse } from '../model/types'; + +const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + +export async function fetchVesselTrack( + mmsi: number, + minutes: number, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + + const combinedSignal = signal ?? controller.signal; + + try { + const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`; + const res = await fetch(url, { + signal: combinedSignal, + headers: { accept: 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`); + } + + const json = (await res.json()) as TrackResponse; + return json; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts new file mode 100644 index 0000000..34214c7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts @@ -0,0 +1,115 @@ +import { haversineNm } from '../../../shared/lib/geo/haversineNm'; +import type { ActiveTrack, NormalizedTrip } from '../model/types'; + +/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */ +export function normalizeTrip( + track: ActiveTrack, + color: [number, number, number], +): NormalizedTrip { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length === 0) { + return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color }; + } + + const baseEpoch = new Date(sorted[0].messageTimestamp).getTime(); + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const pt of sorted) { + path.push([pt.lon, pt.lat]); + // 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋 + timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000); + } + + return { + path, + timestamps, + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + color, + }; +} + +/** Globe 전용 — LineString GeoJSON */ +export function buildTrackLineGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length < 2) { + return { type: 'FeatureCollection', features: [] }; + } + + let totalDistanceNm = 0; + const coordinates: [number, number][] = []; + for (let i = 0; i < sorted.length; i++) { + const pt = sorted[i]; + coordinates.push([pt.lon, pt.lat]); + if (i > 0) { + const prev = sorted[i - 1]; + totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon); + } + } + + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + pointCount: sorted.length, + minutes: track.minutes, + totalDistanceNm: Math.round(totalDistanceNm * 100) / 100, + }, + geometry: { type: 'LineString', coordinates }, + }, + ], + }; +} + +/** Globe+Mercator 공용 — Point GeoJSON */ +export function buildTrackPointsGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + return { + type: 'FeatureCollection', + features: sorted.map((pt, index) => ({ + type: 'Feature' as const, + properties: { + mmsi: pt.mmsi, + name: pt.name, + sog: pt.sog, + cog: pt.cog, + heading: pt.heading, + status: pt.status, + messageTimestamp: pt.messageTimestamp, + index, + }, + geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] }, + })), + }; +} + +export function getTrackTimeRange(trip: NormalizedTrip): { + minTime: number; + maxTime: number; + durationSec: number; +} { + if (trip.timestamps.length === 0) { + return { minTime: 0, maxTime: 0, durationSec: 0 }; + } + const minTime = trip.timestamps[0]; + const maxTime = trip.timestamps[trip.timestamps.length - 1]; + return { minTime, maxTime, durationSec: maxTime - minTime }; +} diff --git a/apps/web/src/entities/vesselTrack/model/types.ts b/apps/web/src/entities/vesselTrack/model/types.ts new file mode 100644 index 0000000..96df8d7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/model/types.ts @@ -0,0 +1,39 @@ +export interface TrackPoint { + mmsi: number; + name: string; + lat: number; + lon: number; + heading: number; + sog: number; + cog: number; + rot: number; + length: number; + width: number; + draught: number; + status: string; + messageTimestamp: string; + receivedDate: string; + source: string; +} + +export interface TrackResponse { + success: boolean; + message: string; + data: TrackPoint[]; +} + +export interface ActiveTrack { + mmsi: number; + minutes: number; + points: TrackPoint[]; + fetchedAt: number; +} + +/** TripsLayer용 정규화 데이터 */ +export interface NormalizedTrip { + path: [number, number][]; + timestamps: number[]; + mmsi: number; + name: string; + color: [number, number, number]; +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index a66abc6..20946eb 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -27,6 +27,8 @@ import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; +import type { ActiveTrack } from "../../entities/vesselTrack/model/types"; +import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; @@ -99,7 +101,7 @@ export function DashboardPage() { const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const uid = null; + const uid = user?.id ?? null; const [typeEnabled, setTypeEnabled] = usePersistedState>( uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, ); @@ -129,6 +131,29 @@ export function DashboardPage() { const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); + // 항적 (vessel track) + const [activeTrack, setActiveTrack] = useState(null); + const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); + const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => { + setTrackContextMenu(info); + }, []); + const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); + const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { + try { + const res = await fetchVesselTrack(mmsi, minutes); + if (res.success && res.data.length > 0) { + const sorted = [...res.data].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() }); + } else { + console.warn('Track: no data', res.message); + } + } catch (e) { + console.warn('Track fetch failed:', e); + } + }, []); + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, }); @@ -729,6 +754,11 @@ export function DashboardPage() { mapStyleSettings={mapStyleSettings} initialView={mapView} onViewStateChange={setMapView} + activeTrack={activeTrack} + trackContextMenu={trackContextMenu} + onRequestTrack={handleRequestTrack} + onCloseTrackMenu={handleCloseTrackMenu} + onOpenTrackMenu={handleOpenTrackMenu} /> diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index ae66351..2394a52 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,7 +26,9 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; +import { useVesselTrackLayer } from './hooks/useVesselTrackLayer'; import { useMapStyleSettings } from './hooks/useMapStyleSettings'; +import { VesselContextMenu } from './components/VesselContextMenu'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -69,6 +71,11 @@ export function Map3D({ initialView, onViewStateChange, onGlobeShipsReady, + activeTrack = null, + trackContextMenu = null, + onRequestTrack, + onCloseTrackMenu, + onOpenTrackMenu, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -94,6 +101,7 @@ export function Map3D({ // ── Hover state ────────────────────────────────────────────────────── const { + hoveredDeckMmsiSet: hoveredDeckMmsiArr, setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, setHoveredDeckFleetOwnerKey, @@ -527,10 +535,80 @@ export function Map3D({ }, ); + useVesselTrackLayer( + mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch }, + ); + + // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 + // Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures + const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr); + useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onContextMenu = (e: MouseEvent) => { + e.preventDefault(); + if (!onOpenTrackMenu) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return; + + let mmsi: number | null = null; + + if (projectionRef.current === 'globe') { + // Globe: MapLibre 네이티브 레이어에서 쿼리 + const point: [number, number] = [e.offsetX, e.offsetY]; + const shipLayerIds = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + ].filter((id) => map.getLayer(id)); + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + if (shipLayerIds.length > 0) { + features = map.queryRenderedFeatures(point, { layers: shipLayerIds }); + } + } catch { /* ignore */ } + + if (features.length > 0) { + const props = features[0].properties || {}; + const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi); + if (Number.isFinite(raw) && raw > 0) mmsi = raw; + } + } else { + // Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용 + const hovered = hoveredDeckMmsiRef.current; + if (hovered.length > 0) mmsi = hovered[0]; + } + + if (mmsi == null || !legacyHits?.has(mmsi)) return; + + const target = shipByMmsi.get(mmsi); + const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`; + onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName }); + }; + container.addEventListener('contextmenu', onContextMenu); + return () => container.removeEventListener('contextmenu', onContextMenu); + }, [onOpenTrackMenu, legacyHits, shipByMmsi]); + useFlyTo( mapRef, projectionRef, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, ); - return
; + return ( + <> +
+ {trackContextMenu && onRequestTrack && onCloseTrackMenu && ( + + )} + + ); } diff --git a/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx new file mode 100644 index 0000000..5cc0591 --- /dev/null +++ b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; + +interface Props { + x: number; + y: number; + mmsi: number; + vesselName: string; + onRequestTrack: (mmsi: number, minutes: number) => void; + onClose: () => void; +} + +const TRACK_OPTIONS = [ + { label: '6시간', minutes: 360 }, + { label: '12시간', minutes: 720 }, + { label: '1일', minutes: 1440 }, + { label: '3일', minutes: 4320 }, + { label: '5일', minutes: 7200 }, +] as const; + +const MENU_WIDTH = 180; +const MENU_PAD = 8; + +export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) { + const ref = useRef(null); + + // 화면 밖 보정 + const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD); + const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD; + const top = Math.min(y, maxTop); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onScroll = () => onClose(); + + window.addEventListener('keydown', onKey); + window.addEventListener('mousedown', onClick, true); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('mousedown', onClick, true); + window.removeEventListener('scroll', onScroll, true); + }; + }, [onClose]); + + const handleSelect = (minutes: number) => { + onRequestTrack(mmsi, minutes); + onClose(); + }; + + return ( +
+ {/* Header */} +
+ {vesselName} +
+ + {/* 항적조회 항목 */} +
+ 항적조회 +
+ + {TRACK_OPTIONS.map((opt) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index af52639..fcabe3b 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -50,6 +50,7 @@ import { getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; +import { getCachedShipIcon } from '../lib/shipIconCache'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). @@ -380,7 +381,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -403,7 +404,7 @@ export function useDeckLayers( pickable: false, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -448,7 +449,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -484,7 +485,7 @@ export function useDeckLayers( if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); } const normalizedLayers = sanitizeDeckLayerList(layers); diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 9edefc9..1165ab9 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) { if (id.startsWith('fc-')) continue; if (id.startsWith('fleet-')) continue; if (id.startsWith('predict-')) continue; + if (id.startsWith('vessel-track-')) continue; if (id === 'deck-globe') continue; const sourceLayer = String((layer as Record)['source-layer'] ?? ''); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 9bc33de..4b3e3cc 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -94,6 +94,11 @@ export function useProjectionToggle( 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', 'zones-fill', 'zones-line', 'zones-label', diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 7fcc138..23c290c 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line'; const GLOW_ID = 'subcables-glow'; const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; +const HOVER_LABEL_ID = 'subcables-hover-label'; /* ── Paint defaults (used for layer creation + hover reset) ──────── */ const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; @@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [ type: 'line', sourceId: SRC_ID, paint: { - 'line-color': ['get', 'color'], + 'line-color': '#ffffff', 'line-opacity': 0, - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8], }, filter: ['==', ['get', 'id'], ''], layout: { 'line-cap': 'round', 'line-join': 'round' }, @@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [ }, minzoom: 4, }, + { + id: HOVER_LABEL_ID, + type: 'symbol', + sourceId: SRC_ID, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': 'rgba(0,0,0,0.85)', + 'text-halo-width': 2, + 'text-halo-blur': 0.5, + 'text-opacity': 0, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': ['get', 'name'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20], + 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + 'text-allow-overlap': true, + 'text-padding': 2, + 'text-rotation-alignment': 'map', + }, + filter: ['==', ['get', 'id'], ''], + minzoom: 2, + }, ]; export function useSubcablesLayer( @@ -250,42 +274,27 @@ export function useSubcablesLayer( } /* ── Hover highlight helper (paint-only mutations) ────────────────── */ +// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조 function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { + const noMatch = ['==', ['get', 'id'], ''] as never; if (hoveredId) { const matchExpr = ['==', ['get', 'id'], hoveredId]; - - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); - map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); - map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); - } if (map.getLayer(GLOW_ID)) { map.setFilter(GLOW_ID, matchExpr as never); - map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, matchExpr as never); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0); } } else { - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never); - map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never); - map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never); - } if (map.getLayer(GLOW_ID)) { - map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setFilter(GLOW_ID, noMatch); map.setPaintProperty(GLOW_ID, 'line-opacity', 0); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, noMatch); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0); } } } diff --git a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts new file mode 100644 index 0000000..e46bc6f --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts @@ -0,0 +1,395 @@ +/** + * useVesselTrackLayer — 항적(Track) 렌더링 hook + * + * Mercator: TripsLayer 애니메이션 + ScatterplotLayer 포인트 + * Globe: MapLibre 네이티브 line + circle + symbol(arrow) + */ +import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types'; +import { + normalizeTrip, + buildTrackLineGeoJson, + buildTrackPointsGeoJson, + getTrackTimeRange, +} from '../../../entities/vesselTrack/lib/buildTrackGeoJson'; +import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips'; +import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers'; +import type { MapProjectionId } from '../types'; + +/* ── Constants ──────────────────────────────────────────────────────── */ +const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan +const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`; + +// Globe 네이티브 레이어/소스 ID +const LINE_SRC = 'vessel-track-line-src'; +const PTS_SRC = 'vessel-track-pts-src'; +const LINE_ID = 'vessel-track-line'; +const ARROW_ID = 'vessel-track-arrow'; +const HITAREA_ID = 'vessel-track-line-hitarea'; +const PTS_ID = 'vessel-track-pts'; +const PTS_HL_ID = 'vessel-track-pts-highlight'; + +// Mercator Deck.gl 레이어 ID +const DECK_PATH_ID = 'vessel-track-path'; +const DECK_TRIPS_ID = 'vessel-track-trips'; +const DECK_POINTS_ID = 'vessel-track-deck-pts'; + +/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */ +const GLOBE_LAYERS: NativeLayerSpec[] = [ + { + id: LINE_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { + 'line-color': TRACK_COLOR_CSS, + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4], + 'line-opacity': 0.8, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: HITAREA_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: ARROW_ID, + type: 'symbol', + sourceId: LINE_SRC, + paint: { + 'text-color': TRACK_COLOR_CSS, + 'text-opacity': 0.7, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': '▶', + 'text-size': 10, + 'symbol-spacing': 80, + 'text-rotation-alignment': 'map', + 'text-allow-overlap': true, + 'text-ignore-placement': true, + }, + }, + { + id: PTS_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5], + 'circle-color': TRACK_COLOR_CSS, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-opacity': 0.85, + }, + }, + { + id: PTS_HL_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12], + 'circle-color': '#ffffff', + 'circle-stroke-width': 2, + 'circle-stroke-color': TRACK_COLOR_CSS, + 'circle-opacity': 0, + }, + filter: ['==', ['get', 'index'], -1], + }, +]; + +/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */ +const ANIM_CYCLE_SEC = 20; + +/* ── Hook ──────────────────────────────────────────────────────────── */ +export function useVesselTrackLayer( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + activeTrack: ActiveTrack | null; + projection: MapProjectionId; + mapSyncEpoch: number; + }, +) { + const { activeTrack, projection, mapSyncEpoch } = opts; + + /* ── 정규화 데이터 ── */ + const normalizedTrip = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return normalizeTrip(activeTrack, TRACK_COLOR); + }, [activeTrack]); + + const timeRange = useMemo(() => { + if (!normalizedTrip) return null; + return getTrackTimeRange(normalizedTrip); + }, [normalizedTrip]); + + /* ── Globe 네이티브 GeoJSON ── */ + const lineGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return buildTrackLineGeoJson(activeTrack); + }, [activeTrack]); + + const pointsGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length === 0) return null; + return buildTrackPointsGeoJson(activeTrack); + }, [activeTrack]); + + /* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */ + const globeSources = useMemo(() => [ + { id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } }, + { id: PTS_SRC, data: pointsGeoJson }, + ], [lineGeoJson, pointsGeoJson]); + + const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2; + + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: globeSources, + layers: GLOBE_LAYERS, + visible: isGlobeVisible, + beforeLayer: ['zones-fill', 'zones-line'], + }, + [lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch], + ); + + /* ── Globe 호버 툴팁 ── */ + const tooltipRef = useRef(null); + + const clearTooltip = useCallback(() => { + try { tooltipRef.current?.remove(); } catch { /* ignore */ } + tooltipRef.current = null; + }, []); + + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== 'globe' || !activeTrack) { + clearTooltip(); + return; + } + + const onMove = (e: maplibregl.MapMouseEvent) => { + if (projectionBusyRef.current || !map.isStyleLoaded()) { + clearTooltip(); + return; + } + + const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id)); + if (layers.length === 0) { clearTooltip(); return; } + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + features = map.queryRenderedFeatures(e.point, { layers }); + } catch { /* ignore */ } + + if (features.length === 0) { + clearTooltip(); + // 하이라이트 리셋 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + return; + } + + const feat = features[0]; + const props = feat.properties || {}; + const layerId = feat.layer?.id; + let tooltipHtml = ''; + + if (layerId === PTS_ID && props.index != null) { + tooltipHtml = getTrackPointTooltipHtml({ + name: String(props.name ?? ''), + sog: Number(props.sog), + cog: Number(props.cog), + heading: Number(props.heading), + status: String(props.status ?? ''), + messageTimestamp: String(props.messageTimestamp ?? ''), + }).html; + // 하이라이트 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8); + } + } catch { /* ignore */ } + } else if (layerId === HITAREA_ID) { + tooltipHtml = getTrackLineTooltipHtml({ + name: String(props.name ?? ''), + pointCount: Number(props.pointCount ?? 0), + minutes: Number(props.minutes ?? 0), + totalDistanceNm: Number(props.totalDistanceNm ?? 0), + }).html; + } + + if (!tooltipHtml) { clearTooltip(); return; } + + if (!tooltipRef.current) { + tooltipRef.current = new maplibregl.Popup({ + closeButton: false, closeOnClick: false, + maxWidth: '360px', className: 'maplibre-tooltip-popup', + }); + } + const container = document.createElement('div'); + container.className = 'maplibre-tooltip-popup__content'; + container.innerHTML = tooltipHtml; + tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map); + }; + + const onOut = () => { + clearTooltip(); + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + }; + + map.on('mousemove', onMove); + map.on('mouseout', onOut); + return () => { + map.off('mousemove', onMove); + map.off('mouseout', onOut); + clearTooltip(); + }; + }, [projection, activeTrack, clearTooltip]); + + /* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */ + const animRef = useRef(0); + + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay || projection !== 'mercator') { + cancelAnimationFrame(animRef.current); + return; + } + + const isTrackLayer = (id?: string) => + id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID; + + if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) { + cancelAnimationFrame(animRef.current); + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + if (filtered.length !== (existing as unknown[]).length) { + overlay.setProps({ layers: filtered } as never); + } + } catch { /* ignore */ } + return; + } + + // 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용 + const pathLayer = new PathLayer({ + id: DECK_PATH_ID, + data: [normalizedTrip], + getPath: (d) => d.path, + getColor: [...TRACK_COLOR, 90] as [number, number, number, number], + getWidth: 2, + widthMinPixels: 2, + widthUnits: 'pixels' as const, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + const sorted = [...activeTrack.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + const pointsLayer = new ScatterplotLayer({ + id: DECK_POINTS_ID, + data: sorted, + getPosition: (d) => [d.lon, d.lat], + getRadius: 4, + radiusUnits: 'pixels' as const, + getFillColor: TRACK_COLOR, + getLineColor: [0, 0, 0, 128], + lineWidthMinPixels: 1, + stroked: true, + pickable: true, + }); + + // rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음) + const { minTime, maxTime, durationSec } = timeRange; + const speed = durationSec / ANIM_CYCLE_SEC; + let current = minTime; + + const loop = () => { + current += speed / 60; + if (current > maxTime) current = minTime; + + const tripsLayer = new TripsLayer({ + id: DECK_TRIPS_ID, + data: [normalizedTrip], + getPath: (d: NormalizedTrip) => d.path, + getTimestamps: (d: NormalizedTrip) => d.timestamps, + getColor: (d: NormalizedTrip) => d.color, + currentTime: current, + trailLength: durationSec * 0.15, + fadeTrail: true, + widthMinPixels: 4, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never); + } catch { /* ignore */ } + + animRef.current = requestAnimationFrame(loop); + }; + + animRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(animRef.current); + }, [projection, normalizedTrip, activeTrack, timeRange]); + + /* ── 항적 조회 시 자동 fitBounds ── */ + useEffect(() => { + const map = mapRef.current; + if (!map || !activeTrack || activeTrack.points.length < 2) return; + if (projectionBusyRef.current) return; + + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const pt of activeTrack.points) { + if (pt.lon < minLon) minLon = pt.lon; + if (pt.lat < minLat) minLat = pt.lat; + if (pt.lon > maxLon) maxLon = pt.lon; + if (pt.lat > maxLat) maxLat = pt.lat; + } + + const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 }; + const apply = () => { + try { + map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts); + } catch { /* ignore */ } + }; + + if (map.isStyleLoaded()) { + apply(); + } else { + const onLoad = () => { apply(); map.off('styledata', onLoad); }; + map.on('styledata', onLoad); + return () => { map.off('styledata', onLoad); }; + } + }, [activeTrack]); +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index f5277a2..ddf9a54 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -36,6 +36,11 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', 'deck-globe', ]; @@ -46,6 +51,8 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'pair-range-ml-src', 'subcables-src', 'subcables-pts-src', + 'vessel-track-line-src', + 'vessel-track-pts-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) { diff --git a/apps/web/src/widgets/map3d/lib/shipIconCache.ts b/apps/web/src/widgets/map3d/lib/shipIconCache.ts new file mode 100644 index 0000000..b7bdd8e --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/shipIconCache.ts @@ -0,0 +1,30 @@ +/** + * Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시. + * Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록 + * 인라인 data URL을 전달한다. + */ +const SHIP_SVG_URL = '/assets/ship.svg'; + +let _cachedDataUrl: string | null = null; +let _promise: Promise | null = null; + +function preloadShipIcon(): Promise { + if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl); + if (_promise) return _promise; + _promise = fetch(SHIP_SVG_URL) + .then((res) => res.text()) + .then((svg) => { + _cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; + return _cachedDataUrl; + }) + .catch(() => SHIP_SVG_URL); + return _promise; +} + +/** 캐시된 data URL 또는 폴백 URL 반환 */ +export function getCachedShipIcon(): string { + return _cachedDataUrl ?? SHIP_SVG_URL; +} + +// 모듈 임포트 시 즉시 로드 시작 +preloadShipIcon(); diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index 5fe4996..340b2f3 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -168,3 +168,54 @@ export function getFleetCircleTooltipHtml({
`, }; } + +function fmtMinutesKr(minutes: number): string { + if (minutes < 60) return `${minutes}분`; + if (minutes < 1440) return `${Math.round(minutes / 60)}시간`; + return `${Math.round(minutes / 1440)}일`; +} + +export function getTrackLineTooltipHtml({ + name, + pointCount, + minutes, + totalDistanceNm, +}: { + name: string; + pointCount: number; + minutes: number; + totalDistanceNm: number; +}) { + return { + html: `
+
항적 · ${name}
+
기간: ${fmtMinutesKr(minutes)} · 포인트: ${pointCount}
+
총 거리: ${totalDistanceNm.toFixed(1)} NM
+
`, + }; +} + +export function getTrackPointTooltipHtml({ + name, + sog, + cog, + heading, + status, + messageTimestamp, +}: { + name: string; + sog: number; + cog: number; + heading: number; + status: string; + messageTimestamp: string; +}) { + return { + html: `
+
${name}
+
SOG: ${isFiniteNumber(sog) ? sog : '?'} kt · COG: ${isFiniteNumber(cog) ? cog : '?'}°
+
Heading: ${isFiniteNumber(heading) ? heading : '?'}° · 상태: ${status || '-'}
+
${fmtIsoFull(messageTimestamp)}
+
`, + }; +} diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index aa6394d..55bfabf 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -1,6 +1,7 @@ import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; import type { SubcableGeoJson } from '../../entities/subcable/model/types'; +import type { ActiveTrack } from '../../entities/vesselTrack/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'; @@ -62,6 +63,11 @@ export interface Map3DProps { initialView?: MapViewState | null; onViewStateChange?: (view: MapViewState) => void; onGlobeShipsReady?: (ready: boolean) => void; + activeTrack?: ActiveTrack | null; + trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null; + onRequestTrack?: (mmsi: number, minutes: number) => void; + onCloseTrackMenu?: () => void; + onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; } export type DashSeg = { diff --git a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx index 9bfec56..07d831a 100644 --- a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx +++ b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx @@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) { const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))]; return ( -
+
-
+ {/* ── Header ── */} +
{color && (
)} -
{detail.name}
+
+ {detail.name} +
-
- Submarine Cable{detail.is_planned ? ' (Planned)' : ''} -
-
- -
- 길이 - {detail.length || '-'} -
-
- 개통 - {detail.rfs || '-'} -
- {detail.owners && ( -
- 운영사 - - {detail.owners} + {detail.is_planned && ( + + Planned -
- )} - {detail.suppliers && ( -
- 공급사 - {detail.suppliers} -
- )} + )} +
+ {/* ── Info rows ── */} +
+ + + {detail.owners && } + {detail.suppliers && } +
+ + {/* ── Landing Points ── */} {landingCount > 0 && ( -
-
- Landing Points ({landingCount}) · {countries.length} countries +
+
+ Landing Points + + {landingCount}곳 · {countries.length}개국 +
{detail.landing_points.map((lp) => ( -
- {lp.country}{' '} - {lp.name} - {lp.is_tbd && TBD} +
+ + {lp.country} + + {lp.name} + {lp.is_tbd && ( + + TBD + + )}
))}
)} + {/* ── Notes ── */} {detail.notes && ( -
+
{detail.notes}
)} + {/* ── Link ── */} {detail.url && ( - ); } + +function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) { + return ( +
+ {label} + + {value || '-'} + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 0aa3a3d..ff6a498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@react-oauth/google": "^0.13.4", @@ -382,6 +383,57 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.7.tgz", + "integrity": "sha512-jIsep2NByEimWlScqc/NLjpqWknLk5rd+uP8UAl7qI8CTInXV4KdzaYgujL+bE4lSV4Zlg0oMOAkbcviMKDLNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.7.tgz", + "integrity": "sha512-DiEfsmWrW0EIM44FdwzdPAjUr8a8IPelpkt2fPvrgmaS0OSZFB1PkJWLmM3hoYO8mH0vuD0YL//m+Pvtw3bGSw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "^4.3.4", + "@loaders.gl/gis": "^4.3.4", + "@loaders.gl/loader-utils": "^4.3.4", + "@loaders.gl/mvt": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@loaders.gl/terrain": "^4.3.4", + "@loaders.gl/tiles": "^4.3.4", + "@loaders.gl/wms": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "^4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/layers": { "version": "9.2.7", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.7.tgz", @@ -421,6 +473,26 @@ "@math.gl/web-mercator": "^4.1.0" } }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.7.tgz", + "integrity": "sha512-EpWHJ3GaCXELCsYRlabvkXxtgLQwOZYU8YPOmlKUYf+/410B2D89oNGtJinRcfM1/T9TBelBS9CHMYsL1tv9cA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/gltf": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1275,6 +1347,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/core": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", @@ -1288,6 +1415,96 @@ "@probe.gl/log": "^4.0.2" } }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/images": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", @@ -1315,6 +1532,51 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", @@ -1327,6 +1589,74 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", @@ -1336,11 +1666,42 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@luma.gl/core": { "version": "9.2.6", @@ -1373,6 +1734,24 @@ "@luma.gl/shadertools": "~9.2.0" } }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, "node_modules/@luma.gl/shadertools": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", @@ -1423,6 +1802,12 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1520,6 +1905,26 @@ "@math.gl/types": "4.1.0" } }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, "node_modules/@math.gl/polygon": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", @@ -1944,6 +2349,62 @@ "win32" ] }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1989,6 +2450,21 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2013,7 +2489,6 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2025,6 +2500,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2354,6 +2835,15 @@ "resolved": "apps/web", "link": true }, + "node_modules/a5-js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2509,6 +2999,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2530,6 +3041,16 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2565,6 +3086,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2627,6 +3157,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -2684,6 +3223,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2712,6 +3270,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2750,6 +3317,18 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2759,6 +3338,12 @@ "node": ">=6" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -3119,6 +3704,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", @@ -3207,6 +3810,12 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3364,6 +3973,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3391,6 +4011,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3401,6 +4041,24 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3428,6 +4086,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -3437,6 +4101,18 @@ "node": ">= 10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3460,6 +4136,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3559,6 +4241,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -3575,6 +4269,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3589,6 +4289,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -3649,6 +4358,15 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3659,6 +4377,19 @@ "yallist": "^3.0.2" } }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, "node_modules/maplibre-gl": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", @@ -3702,6 +4433,17 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3835,6 +4577,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3983,6 +4731,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4082,6 +4836,21 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -4205,6 +4974,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -4271,6 +5046,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4294,6 +5075,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -4322,6 +5109,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4335,6 +5128,15 @@ "node": ">=0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4348,6 +5150,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -4370,6 +5184,28 @@ "node": ">=8" } }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/texture-compressor/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -4503,7 +5339,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4547,6 +5382,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4740,6 +5581,13 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true } } } -- 2.45.2