diff --git a/apps/web/package.json b/apps/web/package.json index a28712e..629c7e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,11 +15,15 @@ "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", + "@maptiler/weather": "^3.1.1", "@react-oauth/google": "^0.13.4", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router": "^7.13.0" + "react-router": "^7.13.0", + "@deck.gl/extensions": "^9.2.7", + "@stomp/stompjs": "^7.2.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/apps/web/public/sw-weather-cache.js b/apps/web/public/sw-weather-cache.js new file mode 100644 index 0000000..3750815 --- /dev/null +++ b/apps/web/public/sw-weather-cache.js @@ -0,0 +1,62 @@ +/** + * Weather Tile Cache ServiceWorker + * + * MapTiler Weather SDK 타일(Image 요소로 로드)을 Cache API로 캐싱. + * 같은 tileset_id + z/x/y 좌표 → 동일 URL → cache-first 전략. + * + * 캐시 최대 2000장, 초과 시 가장 오래된 항목부터 제거. + */ + +const CACHE_NAME = 'weather-tiles-v1'; +const MAX_ENTRIES = 2000; + +/** api.maptiler.com/tiles/ 패턴만 캐싱 */ +const TILE_RE = /api\.maptiler\.com\/tiles\//; + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k.startsWith('weather-tiles-') && k !== CACHE_NAME) + .map((k) => caches.delete(k)), + ), + ), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const url = event.request.url; + if (!TILE_RE.test(url)) return; // 타일 외 요청은 패스스루 + + event.respondWith( + caches.open(CACHE_NAME).then(async (cache) => { + // 1. 캐시 히트 → 즉시 반환 + const cached = await cache.match(event.request); + if (cached) return cached; + + // 2. 네트워크 fetch + const response = await fetch(event.request); + if (response.ok) { + // 비동기 캐시 저장 (응답 지연 없음) + cache.put(event.request, response.clone()).then(() => trimCache(cache)); + } + return response; + }), + ); +}); + +/** 캐시 항목이 MAX_ENTRIES 초과 시 오래된 것부터 삭제 */ +async function trimCache(cache) { + const keys = await cache.keys(); + if (keys.length <= MAX_ENTRIES) return; + const excess = keys.length - MAX_ENTRIES; + for (let i = 0; i < excess; i++) { + await cache.delete(keys[i]); + } +} diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 127b3dc..9e8c645 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -1259,6 +1259,549 @@ body { border-color: var(--accent); } +/* ── Weather Overlay Panel ─────────────────────────────────── */ + +.weather-gear { + position: absolute; + top: 140px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.weather-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.weather-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.weather-panel { + position: absolute; + top: 130px; + left: 48px; + z-index: 850; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + width: 260px; + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +.wp-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.wp-title { + font-size: 11px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; +} + +.wp-loading { + font-size: 9px; + color: var(--accent); +} + +.wp-error { + font-size: 9px; + color: #f87171; + margin-bottom: 6px; + padding: 4px 6px; + background: rgba(248, 113, 113, 0.1); + border-radius: 4px; +} + +.wp-empty { + font-size: 10px; + color: var(--muted); + text-align: center; + padding: 12px 0; +} + +.wz-card { + border-left: 3px solid var(--border); + padding: 6px 8px; + margin-bottom: 6px; + border-radius: 0 4px 4px 0; + background: rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +.wz-card:last-of-type { + margin-bottom: 0; +} + +.wz-card.wz-warn { + background: rgba(248, 113, 113, 0.08); +} + +.wz-name { + font-size: 10px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + letter-spacing: 0.3px; +} + +.wz-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.wz-item { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--muted); + white-space: nowrap; +} + +.wz-icon { + font-size: 10px; + color: var(--accent); +} + +.wz-label { + font-size: 9px; + color: var(--muted); +} + +.wz-value { + font-size: 10px; + font-weight: 600; + color: var(--text); +} + +.wz-weather { + font-weight: 500; + color: var(--muted); +} + +.wp-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.wp-time { + font-size: 9px; + color: var(--muted); +} + +.wp-refresh { + font-size: 14px; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + width: 22px; + height: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, border-color 0.15s; + padding: 0; +} + +.wp-refresh:hover { + color: var(--text); + border-color: var(--accent); +} + +.wp-refresh:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */ + +.wo-gear { + position: absolute; + top: 180px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.wo-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.wo-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.wo-gear.active { + border-color: #22c55e; +} + +.wo-gear.active.open { + border-color: var(--accent); +} + +.wo-gear-badge { + position: absolute; + top: -4px; + right: -4px; + background: #22c55e; + color: #fff; + font-size: 8px; + font-weight: 700; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.wo-stack { + position: absolute; + top: 170px; + left: 48px; + z-index: 850; + display: flex; + flex-direction: column; + gap: 6px; + width: 280px; + pointer-events: none; +} +.wo-stack > * { + pointer-events: auto; +} + +.wo-panel { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + width: 100%; + max-height: calc(100vh - 240px); + overflow-y: auto; +} + +.wo-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.wo-title { + font-size: 11px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; +} + +.wo-loading { + font-size: 9px; + color: var(--accent); + animation: wo-pulse 1.2s ease-in-out infinite; +} + +@keyframes wo-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.wo-layers { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + margin-bottom: 10px; +} + +.wo-layer-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 4px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + cursor: pointer; + transition: all 0.15s; + font-size: 10px; +} + +.wo-layer-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-color: rgba(59, 130, 246, 0.4); +} + +.wo-layer-btn.on { + background: rgba(59, 130, 246, 0.15); + color: var(--text); + border-color: var(--accent); +} + +.wo-layer-icon { + font-size: 16px; + line-height: 1; +} + +.wo-layer-name { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.wo-section { + margin-bottom: 8px; +} + +.wo-label { + font-size: 9px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.wo-val { + font-weight: 400; + color: var(--muted); + font-size: 9px; +} +.wo-offset { + color: #4fc3f7; + font-weight: 600; +} + +.wo-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.wo-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--panel); + cursor: pointer; +} + +.wo-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--panel); + cursor: pointer; +} + +.wo-slider:disabled { + opacity: 0.4; + cursor: default; +} + +.wo-timeline { + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.wo-step-slider-wrap { + position: relative; + padding-bottom: 10px; +} + +.wo-time-slider { + margin-bottom: 2px; +} + +.wo-step-ticks { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 8px; + pointer-events: none; +} + +.wo-step-tick { + position: absolute; + bottom: 0; + width: 1px; + height: 4px; + background: var(--muted); + opacity: 0.4; + transform: translateX(-0.5px); +} + +.wo-step-tick.day { + height: 8px; + opacity: 0.8; + background: var(--accent); +} + +.wo-time-range { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + margin-bottom: 6px; +} + +.wo-playback { + display: flex; + align-items: center; + gap: 6px; +} + +.wo-play-btn { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + transition: all 0.15s; + padding: 0; + flex-shrink: 0; +} + +.wo-play-btn:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.12); +} + +.wo-play-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.wo-speed-btns { + display: flex; + gap: 2px; +} + +.wo-speed-btn { + font-size: 8px; + padding: 3px 6px; + border-radius: 3px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all 0.15s; +} + +.wo-speed-btn:hover { + color: var(--text); + border-color: rgba(59, 130, 246, 0.4); +} + +.wo-speed-btn.on { + background: rgba(59, 130, 246, 0.15); + color: var(--text); + border-color: var(--accent); +} + +.wo-hint { + font-size: 8px; + color: var(--muted); + text-align: right; + margin-top: 4px; + opacity: 0.6; +} + +/* ── Weather Legend ── */ +.wo-legend { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px 4px; + width: 100%; +} +.wo-legend-header { + font-size: 9px; + color: var(--muted); + margin-bottom: 4px; + text-align: center; +} +.wo-legend-bar { + height: 10px; + border-radius: 3px; + width: 100%; +} +.wo-legend-ticks { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + margin-top: 2px; +} + @media (max-width: 920px) { .app { grid-template-columns: 1fr; diff --git a/apps/web/src/entities/weather/api/fetchWeather.ts b/apps/web/src/entities/weather/api/fetchWeather.ts new file mode 100644 index 0000000..09fbe6b --- /dev/null +++ b/apps/web/src/entities/weather/api/fetchWeather.ts @@ -0,0 +1,107 @@ +import type { WeatherQueryPoint, WeatherPoint, WeatherSnapshot } from '../model/types'; + +const MARINE_BASE = 'https://marine-api.open-meteo.com/v1/marine'; +const WEATHER_BASE = 'https://api.open-meteo.com/v1/forecast'; + +const MARINE_PARAMS = 'current=wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction,sea_surface_temperature'; +const WEATHER_PARAMS = 'current=wind_speed_10m,wind_direction_10m,wind_gusts_10m,temperature_2m,weather_code'; + +const TIMEOUT_MS = 10_000; + +/* Open-Meteo 다중 좌표 응답 타입 */ +interface MarineCurrentItem { + wave_height?: number; + wave_direction?: number; + wave_period?: number; + swell_wave_height?: number; + swell_wave_direction?: number; + sea_surface_temperature?: number; +} + +interface WeatherCurrentItem { + wind_speed_10m?: number; + wind_direction_10m?: number; + wind_gusts_10m?: number; + temperature_2m?: number; + weather_code?: number; +} + +/* 단일 좌표 응답 */ +interface SingleMarineResponse { + current?: MarineCurrentItem; +} + +/* 다중 좌표 응답 — 배열 */ +type MarineResponse = SingleMarineResponse | SingleMarineResponse[]; + +interface SingleWeatherResponse { + current?: WeatherCurrentItem; +} + +type WeatherResponse = SingleWeatherResponse | SingleWeatherResponse[]; + +function buildMultiCoordParams(points: WeatherQueryPoint[]): string { + const lats = points.map((p) => p.lat.toFixed(2)).join(','); + const lons = points.map((p) => p.lon.toFixed(2)).join(','); + return `latitude=${lats}&longitude=${lons}`; +} + +function n(v: number | undefined): number | null { + return v != null && Number.isFinite(v) ? v : null; +} + +/** + * Open-Meteo Marine + Weather API를 병렬 호출하여 기상 스냅샷 반환. + * 좌표 배열 기반이므로 수역 centroid 외에도 임의 좌표에 활용 가능. + */ +export async function fetchWeatherForPoints( + points: WeatherQueryPoint[], +): Promise { + if (points.length === 0) { + return { points: [], fetchedAt: Date.now() }; + } + + const coords = buildMultiCoordParams(points); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), TIMEOUT_MS); + + try { + const [marineRaw, weatherRaw] = await Promise.all([ + fetch(`${MARINE_BASE}?${coords}&${MARINE_PARAMS}`, { signal: ac.signal }) + .then((r) => r.json() as Promise), + fetch(`${WEATHER_BASE}?${coords}&${WEATHER_PARAMS}`, { signal: ac.signal }) + .then((r) => r.json() as Promise), + ]); + + // 단일 좌표면 배열이 아닌 단일 객체가 반환됨 → 통일 + const marines: SingleMarineResponse[] = Array.isArray(marineRaw) ? marineRaw : [marineRaw]; + const weathers: SingleWeatherResponse[] = Array.isArray(weatherRaw) ? weatherRaw : [weatherRaw]; + + const result: WeatherPoint[] = points.map((pt, i) => { + const mc = marines[i]?.current; + const wc = weathers[i]?.current; + return { + label: pt.label, + color: pt.color, + lat: pt.lat, + lon: pt.lon, + zoneId: pt.zoneId, + waveHeight: n(mc?.wave_height), + waveDirection: n(mc?.wave_direction), + wavePeriod: n(mc?.wave_period), + swellHeight: n(mc?.swell_wave_height), + swellDirection: n(mc?.swell_wave_direction), + seaSurfaceTemp: n(mc?.sea_surface_temperature), + windSpeed: n(wc?.wind_speed_10m), + windDirection: n(wc?.wind_direction_10m), + windGusts: n(wc?.wind_gusts_10m), + temperature: n(wc?.temperature_2m), + weatherCode: n(wc?.weather_code), + }; + }); + + return { points: result, fetchedAt: Date.now() }; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/web/src/entities/weather/lib/weatherUtils.ts b/apps/web/src/entities/weather/lib/weatherUtils.ts new file mode 100644 index 0000000..29b0ccf --- /dev/null +++ b/apps/web/src/entities/weather/lib/weatherUtils.ts @@ -0,0 +1,78 @@ +/** 기상 데이터 유틸리티 */ + +const DIRECTION_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const; +const DIRECTION_ARROWS = ['↓', '↙', '←', '↖', '↑', '↗', '→', '↘'] as const; + +/** + * 풍향 각도 → 8방위 라벨. + * 풍향은 "바람이 불어오는 방향"이므로 0° = 북풍(N). + */ +export function getWindDirectionLabel(deg: number | null): string { + if (deg == null) return '-'; + const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8; + return DIRECTION_LABELS[idx]; +} + +/** + * 풍향 각도 → 화살표 문자. + * 바람이 불어가는 방향을 가리킴 (풍향의 반대). + */ +export function getWindArrow(deg: number | null): string { + if (deg == null) return ''; + const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8; + return DIRECTION_ARROWS[idx]; +} + +export type WaveSeverity = 'calm' | 'moderate' | 'rough' | 'severe'; + +/** 파고 등급 분류 */ +export function getWaveSeverity(m: number | null): WaveSeverity { + if (m == null || m < 0.5) return 'calm'; + if (m < 1.5) return 'moderate'; + if (m < 2.5) return 'rough'; + return 'severe'; +} + +/** + * WMO Weather Interpretation Code → 한글 라벨. + * https://open-meteo.com/en/docs 참조. + */ +export function getWeatherLabel(code: number | null): string { + if (code == null) return '-'; + if (code === 0) return '맑음'; + if (code <= 3) return ['약간 흐림', '흐림', '매우 흐림'][code - 1]; + if (code <= 49) return '안개'; + if (code <= 59) return '이슬비'; + if (code <= 69) return '비'; + if (code <= 79) return '눈'; + if (code <= 84) return '소나기'; + if (code <= 94) return '뇌우'; + if (code <= 99) return '뇌우(우박)'; + return '-'; +} + +/** + * MultiPolygon 좌표 배열에서 산술 평균 centroid 계산. + * GeoJSON MultiPolygon: number[][][][] + */ +export function computeMultiPolygonCentroid( + coordinates: number[][][][], +): [number, number] { + let sumLon = 0; + let sumLat = 0; + let count = 0; + + for (const polygon of coordinates) { + // 외곽 링만 사용 (polygon[0]) + const ring = polygon[0]; + if (!ring) continue; + for (const coord of ring) { + sumLon += coord[0]; + sumLat += coord[1]; + count++; + } + } + + if (count === 0) return [0, 0]; + return [sumLon / count, sumLat / count]; +} diff --git a/apps/web/src/entities/weather/model/types.ts b/apps/web/src/entities/weather/model/types.ts new file mode 100644 index 0000000..f1772bd --- /dev/null +++ b/apps/web/src/entities/weather/model/types.ts @@ -0,0 +1,40 @@ +import type { ZoneId } from '../../zone/model/meta'; + +/** 기상 조회 대상 지점 (입력용) */ +export interface WeatherQueryPoint { + label: string; + color: string; + lat: number; + lon: number; + zoneId?: ZoneId; +} + +/** 단일 지점의 기상 데이터 */ +export interface WeatherPoint { + label: string; + color: string; + lat: number; + lon: number; + zoneId?: ZoneId; + + /* Marine */ + waveHeight: number | null; + waveDirection: number | null; + wavePeriod: number | null; + swellHeight: number | null; + swellDirection: number | null; + seaSurfaceTemp: number | null; + + /* Weather */ + windSpeed: number | null; + windDirection: number | null; + windGusts: number | null; + temperature: number | null; + weatherCode: number | null; +} + +/** 기상 스냅샷 (전체 지점 묶음) */ +export interface WeatherSnapshot { + points: WeatherPoint[]; + fetchedAt: number; +} diff --git a/apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts b/apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts new file mode 100644 index 0000000..fe6288d --- /dev/null +++ b/apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts @@ -0,0 +1,240 @@ +import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types'; + +interface RenderConfig { + defaultMinInterval: number; + maxInterval: number; + targetRenderTime: number; + maxRenderTime: number; +} + +interface DensityConfig { + maxZoom: number; + maxPerCell: number; + gridSizeMultiplier: number; +} + +type RenderCallback = (ships: LiveShipFeature[], trigger: number) => void; + +const RENDER_CONFIG: RenderConfig = { + defaultMinInterval: 1000, + maxInterval: 5000, + targetRenderTime: 100, + maxRenderTime: 500, +}; + +const ZOOM_MIN_INTERVAL: Record = { + 7: 4000, + 8: 3500, + 9: 3000, + 10: 2500, + 11: 2000, + 12: 1500, + 13: 1500, + 14: 1000, +}; + +const DENSITY_LIMITS: DensityConfig[] = [ + { maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 }, + { maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 }, + { maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 }, + { maxZoom: 8, maxPerCell: 35, gridSizeMultiplier: 75 }, + { maxZoom: 9, maxPerCell: 38, gridSizeMultiplier: 70 }, + { maxZoom: 10, maxPerCell: 40, gridSizeMultiplier: 55 }, + { maxZoom: 11, maxPerCell: 43, gridSizeMultiplier: 40 }, + { maxZoom: Number.POSITIVE_INFINITY, maxPerCell: Number.POSITIVE_INFINITY, gridSizeMultiplier: 30 }, +]; + +const SHIP_KIND_PRIORITY: Record = { + '000021': 2, + '000025': 3, + '000022': 4, + '000024': 6, + '000023': 7, + '000020': 8, + '000027': 9, + '000028': 10, +}; + +function getShipPriority(ship: LiveShipFeature): number { + return SHIP_KIND_PRIORITY[ship.signalKindCode] ?? 11; +} + +function getDensityConfig(zoomLevel: number): DensityConfig { + for (const config of DENSITY_LIMITS) { + if (zoomLevel <= config.maxZoom) return config; + } + return DENSITY_LIMITS[DENSITY_LIMITS.length - 1]; +} + +function getMinIntervalByZoom(zoom: number): number { + const zoomInt = Math.floor(zoom); + if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7]; + if (zoomInt >= 14) return ZOOM_MIN_INTERVAL[14]; + return ZOOM_MIN_INTERVAL[zoomInt] || RENDER_CONFIG.defaultMinInterval; +} + +function isInViewport(ship: LiveShipFeature, bounds: ViewportBounds): boolean { + if (ship.latitude < bounds.minLat || ship.latitude > bounds.maxLat) return false; + if (bounds.minLon <= bounds.maxLon) { + return ship.longitude >= bounds.minLon && ship.longitude <= bounds.maxLon; + } + return ship.longitude >= bounds.minLon || ship.longitude <= bounds.maxLon; +} + +function applyDensityLimit(ships: LiveShipFeature[], zoomLevel: number): LiveShipFeature[] { + const config = getDensityConfig(zoomLevel); + if (config.maxPerCell === Number.POSITIVE_INFINITY) return ships; + + const sorted = [...ships].sort((a, b) => getShipPriority(a) - getShipPriority(b)); + const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; + const gridCounts = new Map(); + const result: LiveShipFeature[] = []; + + for (const ship of sorted) { + const gridX = Math.floor(ship.longitude / gridSize); + const gridY = Math.floor(ship.latitude / gridSize); + const key = `${gridX},${gridY}`; + const count = gridCounts.get(key) || 0; + if (count < config.maxPerCell) { + result.push(ship); + gridCounts.set(key, count + 1); + } + } + + return result.reverse(); +} + +class ShipBatchRenderer { + private data: LiveShipFeature[] = []; + private callback: RenderCallback | null = null; + private viewportBounds: ViewportBounds | null = null; + private currentZoom = 10; + private pendingRender = false; + private isRendering = false; + private animationHandle: ReturnType | number | null = null; + private lastRenderTime = 0; + private currentInterval = RENDER_CONFIG.defaultMinInterval; + private renderTrigger = 0; + private lastRenderedShips: LiveShipFeature[] = []; + + initialize(callback: RenderCallback): void { + this.callback = callback; + } + + setData(data: LiveShipFeature[]): void { + this.data = data; + this.requestRender(); + } + + setViewportBounds(bounds: ViewportBounds | null): void { + this.viewportBounds = bounds; + } + + setZoom(zoom: number): boolean { + const prevInt = Math.floor(this.currentZoom); + const nextInt = Math.floor(zoom); + this.currentZoom = zoom; + + const nextMin = getMinIntervalByZoom(zoom); + if (nextMin > this.currentInterval) { + this.currentInterval = nextMin; + } + + return prevInt !== nextInt; + } + + requestRender(): void { + this.pendingRender = true; + if (this.animationHandle != null) return; + this.scheduleRender(); + } + + immediateRender(): void { + if (this.animationHandle != null) { + clearTimeout(this.animationHandle as ReturnType); + cancelAnimationFrame(this.animationHandle as number); + this.animationHandle = null; + } + this.pendingRender = false; + requestAnimationFrame(() => this.executeRender()); + } + + getRenderedShips(): LiveShipFeature[] { + return this.lastRenderedShips; + } + + dispose(): void { + if (this.animationHandle != null) { + clearTimeout(this.animationHandle as ReturnType); + cancelAnimationFrame(this.animationHandle as number); + this.animationHandle = null; + } + this.pendingRender = false; + this.callback = null; + this.lastRenderedShips = []; + } + + private scheduleRender(): void { + const elapsed = Date.now() - this.lastRenderTime; + const delay = Math.max(0, this.currentInterval - elapsed); + + this.animationHandle = setTimeout(() => { + this.animationHandle = requestAnimationFrame(() => this.executeRender()); + }, delay); + } + + private executeRender(): void { + if (this.isRendering) { + this.animationHandle = null; + return; + } + + if (!this.callback) { + this.animationHandle = null; + return; + } + + const startTime = performance.now(); + this.isRendering = true; + + try { + let ships = this.data; + if (this.viewportBounds) { + ships = ships.filter((ship) => isInViewport(ship, this.viewportBounds as ViewportBounds)); + } + + const densityLimited = applyDensityLimit(ships, this.currentZoom); + this.renderTrigger += 1; + this.lastRenderedShips = densityLimited; + this.callback(densityLimited, this.renderTrigger); + + const renderTime = performance.now() - startTime; + this.adjustRenderInterval(renderTime); + } finally { + this.isRendering = false; + this.lastRenderTime = Date.now(); + this.animationHandle = null; + if (this.pendingRender) { + this.pendingRender = false; + this.scheduleRender(); + } + } + } + + private adjustRenderInterval(renderTime: number): void { + const minInterval = getMinIntervalByZoom(this.currentZoom); + + if (renderTime > RENDER_CONFIG.maxRenderTime) { + this.currentInterval = Math.min(this.currentInterval * 1.2, RENDER_CONFIG.maxInterval); + return; + } + + if (renderTime < RENDER_CONFIG.targetRenderTime) { + this.currentInterval = Math.max(this.currentInterval * 0.9, minInterval); + } + } +} + +export { applyDensityLimit, getDensityConfig, getMinIntervalByZoom }; +export type { RenderCallback }; +export default ShipBatchRenderer; diff --git a/apps/web/src/features/liveRenderer/hooks/useLiveShipAdapter.ts b/apps/web/src/features/liveRenderer/hooks/useLiveShipAdapter.ts new file mode 100644 index 0000000..f5e4660 --- /dev/null +++ b/apps/web/src/features/liveRenderer/hooks/useLiveShipAdapter.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { toLiveShipFeatures } from '../lib/adapters'; + +export function useLiveShipAdapter( + targets: AisTarget[], + legacyHits?: Map | null, +) { + return useMemo(() => toLiveShipFeatures(targets, legacyHits), [targets, legacyHits]); +} diff --git a/apps/web/src/features/liveRenderer/hooks/useLiveShipBatchRender.ts b/apps/web/src/features/liveRenderer/hooks/useLiveShipBatchRender.ts new file mode 100644 index 0000000..828d1d9 --- /dev/null +++ b/apps/web/src/features/liveRenderer/hooks/useLiveShipBatchRender.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import ShipBatchRenderer from '../core/ShipBatchRenderer'; +import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types'; + +interface UseLiveShipBatchRenderResult { + renderedFeatures: LiveShipFeature[]; + renderedTargets: AisTarget[]; + renderedMmsiSet: Set; +} + +function getMapBounds(map: maplibregl.Map): ViewportBounds { + const bounds = map.getBounds(); + return { + minLon: bounds.getWest(), + maxLon: bounds.getEast(), + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + }; +} + +export function useLiveShipBatchRender( + mapRef: MutableRefObject, + features: LiveShipFeature[], + sourceTargets: AisTarget[], + mapSyncEpoch: number, +): UseLiveShipBatchRenderResult { + const rendererRef = useRef(null); + const [renderedFeatures, setRenderedFeatures] = useState([]); + + useEffect(() => { + const renderer = new ShipBatchRenderer(); + renderer.initialize((ships) => { + setRenderedFeatures(ships); + }); + rendererRef.current = renderer; + + return () => { + renderer.dispose(); + rendererRef.current = null; + }; + }, []); + + useEffect(() => { + const renderer = rendererRef.current; + if (!renderer) return; + renderer.setData(features); + renderer.immediateRender(); + }, [features]); + + useEffect(() => { + const map = mapRef.current; + const renderer = rendererRef.current; + if (!map || !renderer) return; + + const sync = () => { + if (!rendererRef.current) return; + renderer.setZoom(map.getZoom()); + renderer.setViewportBounds(getMapBounds(map)); + renderer.requestRender(); + }; + + sync(); + map.on('moveend', sync); + map.on('zoomend', sync); + + return () => { + map.off('moveend', sync); + map.off('zoomend', sync); + }; + }, [mapRef, mapSyncEpoch]); + + const renderedMmsiSet = useMemo(() => { + const next = new Set(); + for (const feature of renderedFeatures) { + next.add(feature.mmsi); + } + return next; + }, [renderedFeatures]); + + const renderedTargets = useMemo(() => { + if (renderedMmsiSet.size === 0) return []; + return sourceTargets.filter((target) => renderedMmsiSet.has(target.mmsi)); + }, [sourceTargets, renderedMmsiSet]); + + return { renderedFeatures, renderedTargets, renderedMmsiSet }; +} diff --git a/apps/web/src/features/liveRenderer/lib/adapters.ts b/apps/web/src/features/liveRenderer/lib/adapters.ts new file mode 100644 index 0000000..a1c70e6 --- /dev/null +++ b/apps/web/src/features/liveRenderer/lib/adapters.ts @@ -0,0 +1,63 @@ +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { SIGNAL_KIND, SIGNAL_SOURCE_AIS, type LiveShipFeature, type SignalKindCode } from '../model/liveShip.types'; + +function mapVesselTypeToSignalKind(vesselType: string | undefined): SignalKindCode { + if (!vesselType) return SIGNAL_KIND.NORMAL; + const vt = vesselType.toLowerCase(); + if (vt.includes('fishing')) return SIGNAL_KIND.FISHING; + if (vt.includes('passenger')) return SIGNAL_KIND.PASSENGER; + if (vt.includes('cargo')) return SIGNAL_KIND.CARGO; + if (vt.includes('tanker')) return SIGNAL_KIND.TANKER; + if (vt.includes('military') || vt.includes('law') || vt.includes('government')) return SIGNAL_KIND.GOV; + if (vt.includes('buoy')) return SIGNAL_KIND.BUOY; + return SIGNAL_KIND.NORMAL; +} + +function mapLegacyShipCodeToSignalKind(shipCode: string | undefined): SignalKindCode { + if (!shipCode) return SIGNAL_KIND.NORMAL; + if (shipCode === 'FC') return SIGNAL_KIND.CARGO; + return SIGNAL_KIND.FISHING; +} + +export function toLiveShipFeature(target: AisTarget, legacy: LegacyVesselInfo | undefined | null): LiveShipFeature { + const targetId = String(target.mmsi); + const signalKindCode = legacy + ? mapLegacyShipCodeToSignalKind(legacy.shipCode) + : mapVesselTypeToSignalKind(target.vesselType); + + return { + mmsi: target.mmsi, + featureId: `${SIGNAL_SOURCE_AIS}${targetId}`, + targetId, + originalTargetId: targetId, + signalSourceCode: SIGNAL_SOURCE_AIS, + signalKindCode, + shipName: (target.name || '').trim(), + longitude: target.lon, + latitude: target.lat, + sog: Number.isFinite(target.sog) ? target.sog : 0, + cog: Number.isFinite(target.cog) ? target.cog : 0, + heading: Number.isFinite(target.heading) ? target.heading : 0, + messageTimestamp: target.messageTimestamp || target.receivedDate || new Date().toISOString(), + nationalCode: legacy ? 'CN' : '', + vesselType: target.vesselType, + raw: target, + }; +} + +export function toLiveShipFeatures( + targets: AisTarget[], + legacyHits?: Map | null, +): LiveShipFeature[] { + const out: LiveShipFeature[] = []; + + for (const target of targets) { + if (!target) continue; + if (!Number.isFinite(target.mmsi)) continue; + if (!Number.isFinite(target.lon) || !Number.isFinite(target.lat)) continue; + out.push(toLiveShipFeature(target, legacyHits?.get(target.mmsi) ?? null)); + } + + return out; +} diff --git a/apps/web/src/features/liveRenderer/model/liveShip.types.ts b/apps/web/src/features/liveRenderer/model/liveShip.types.ts new file mode 100644 index 0000000..7ac89f5 --- /dev/null +++ b/apps/web/src/features/liveRenderer/model/liveShip.types.ts @@ -0,0 +1,42 @@ +import type { AisTarget } from '../../../entities/aisTarget/model/types'; + +export const SIGNAL_SOURCE_AIS = '000001'; + +export const SIGNAL_KIND = { + FISHING: '000020', + KCGV: '000021', + PASSENGER: '000022', + CARGO: '000023', + TANKER: '000024', + GOV: '000025', + NORMAL: '000027', + BUOY: '000028', +} as const; + +export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string; + +export interface LiveShipFeature { + mmsi: number; + featureId: string; + targetId: string; + originalTargetId: string; + signalSourceCode: string; + signalKindCode: SignalKindCode; + shipName: string; + longitude: number; + latitude: number; + sog: number; + cog: number; + heading: number; + messageTimestamp: string; + nationalCode: string; + vesselType?: string; + raw?: AisTarget; +} + +export interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} diff --git a/apps/web/src/features/trackReplay/hooks/useTrackReplayDeckLayers.ts b/apps/web/src/features/trackReplay/hooks/useTrackReplayDeckLayers.ts new file mode 100644 index 0000000..5d4b779 --- /dev/null +++ b/apps/web/src/features/trackReplay/hooks/useTrackReplayDeckLayers.ts @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; +import { getCurrentPositions } from '../lib/interpolate'; +import { createReplayTrailLayer } from '../layers/replayLayers'; +import { createDynamicTrackLayers, createStaticTrackLayers } from '../layers/trackLayers'; +import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types'; +import { useTrackPlaybackStore } from '../stores/trackPlaybackStore'; +import { useTrackQueryStore } from '../stores/trackQueryStore'; + +export interface TrackReplayDeckRenderState { + trackReplayDeckLayers: unknown[]; + enabledTracks: ProcessedTrack[]; + currentPositions: CurrentVesselPosition[]; + showPoints: boolean; + showVirtualShip: boolean; + showLabels: boolean; + renderEpoch: number; +} + +export function useTrackReplayDeckLayers(): TrackReplayDeckRenderState { + const tracks = useTrackQueryStore((state) => state.tracks); + const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds); + const highlightedVesselId = useTrackQueryStore((state) => state.highlightedVesselId); + const setHighlightedVesselId = useTrackQueryStore((state) => state.setHighlightedVesselId); + const showPoints = useTrackQueryStore((state) => state.showPoints); + const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip); + const showLabels = useTrackQueryStore((state) => state.showLabels); + const showTrail = useTrackQueryStore((state) => state.showTrail); + const renderEpoch = useTrackQueryStore((state) => state.renderEpoch); + + const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); + const currentTime = useTrackPlaybackStore((state) => state.currentTime); + + const playbackRenderTime = useMemo(() => { + if (!isPlaying) return currentTime; + // Throttle to ~10fps while playing to reduce relayout pressure. + return Math.floor(currentTime / 100) * 100; + }, [isPlaying, currentTime]); + + const enabledTracks = useMemo(() => { + if (!tracks.length) return []; + if (disabledVesselIds.size === 0) return tracks; + return tracks.filter((track) => !disabledVesselIds.has(track.vesselId)); + }, [tracks, disabledVesselIds]); + + const currentPositions = useMemo(() => { + void renderEpoch; + if (enabledTracks.length === 0) return []; + const sampled = getCurrentPositions(enabledTracks, playbackRenderTime); + if (sampled.length > 0 || isPlaying) return sampled; + + // Ensure an immediate first-frame marker when query data arrives but + // playback has not started yet (globe static-render case). + return enabledTracks.flatMap((track) => { + if (track.geometry.length === 0) return []; + const firstTs = track.timestampsMs[0] ?? playbackRenderTime; + return [ + { + vesselId: track.vesselId, + targetId: track.targetId, + sigSrcCd: track.sigSrcCd, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + nationalCode: track.nationalCode, + position: track.geometry[0], + heading: 0, + speed: track.speeds[0] ?? 0, + timestamp: firstTs, + } as CurrentVesselPosition, + ]; + }); + }, [enabledTracks, playbackRenderTime, isPlaying, renderEpoch]); + + const staticLayers = useMemo( + () => { + void renderEpoch; + return createStaticTrackLayers({ + tracks: enabledTracks, + showPoints, + highlightedVesselId, + onPathHover: setHighlightedVesselId, + }); + }, + [enabledTracks, showPoints, highlightedVesselId, setHighlightedVesselId, renderEpoch], + ); + + const dynamicLayers = useMemo( + () => { + void renderEpoch; + return createDynamicTrackLayers({ + currentPositions, + showVirtualShip, + showLabels, + onPathHover: setHighlightedVesselId, + }); + }, + [currentPositions, showVirtualShip, showLabels, setHighlightedVesselId, renderEpoch], + ); + + const trailLayer = useMemo( + () => { + void renderEpoch; + return createReplayTrailLayer({ + tracks: enabledTracks, + currentTime: playbackRenderTime, + showTrail, + }); + }, + [enabledTracks, playbackRenderTime, showTrail, renderEpoch], + ); + + const trackReplayDeckLayers = useMemo( + () => [...staticLayers, ...(trailLayer ? [trailLayer] : []), ...dynamicLayers], + [staticLayers, dynamicLayers, trailLayer], + ); + + return { + trackReplayDeckLayers, + enabledTracks, + currentPositions, + showPoints, + showVirtualShip, + showLabels, + renderEpoch, + }; +} diff --git a/apps/web/src/features/trackReplay/layers/replayLayers.ts b/apps/web/src/features/trackReplay/layers/replayLayers.ts new file mode 100644 index 0000000..013c4ba --- /dev/null +++ b/apps/web/src/features/trackReplay/layers/replayLayers.ts @@ -0,0 +1,60 @@ +import { TripsLayer } from '@deck.gl/geo-layers'; +import type { Layer } from '@deck.gl/core'; +import type { ProcessedTrack } from '../model/track.types'; +import { getShipKindColor } from '../lib/adapters'; +import { TRACK_REPLAY_LAYER_IDS } from './trackLayers'; +import { DEPTH_DISABLED_PARAMS } from '../../../widgets/map3d/constants'; + +interface ReplayTrip { + vesselId: string; + path: [number, number][]; + timestamps: number[]; + color: [number, number, number, number]; +} + +function toReplayTrips(tracks: ProcessedTrack[]): ReplayTrip[] { + const out: ReplayTrip[] = []; + for (const track of tracks) { + if (!track.geometry.length || !track.timestampsMs.length) continue; + const baseTime = track.timestampsMs[0]; + out.push({ + vesselId: track.vesselId, + path: track.geometry, + timestamps: track.timestampsMs.map((ts) => ts - baseTime), + color: getShipKindColor(track.shipKindCode), + }); + } + return out; +} + +export function createReplayTrailLayer(options: { + tracks: ProcessedTrack[]; + currentTime: number; + showTrail: boolean; +}): Layer | null { + const { tracks, currentTime, showTrail } = options; + if (!showTrail || tracks.length === 0) return null; + + const trips = toReplayTrips(tracks); + if (trips.length === 0) return null; + + const minBaseTime = Math.min(...tracks.map((track) => track.timestampsMs[0] || 0)); + const relativeCurrentTime = Math.max(0, currentTime - minBaseTime); + + return new TripsLayer({ + id: TRACK_REPLAY_LAYER_IDS.TRAIL, + data: trips, + getPath: (d) => d.path, + getTimestamps: (d) => d.timestamps, + getColor: (d) => d.color, + currentTime: relativeCurrentTime, + trailLength: 1000 * 60 * 60, + fadeTrail: true, + widthMinPixels: 2, + widthMaxPixels: 4, + capRounded: true, + jointRounded: true, + parameters: DEPTH_DISABLED_PARAMS, + pickable: false, + }); +} diff --git a/apps/web/src/features/trackReplay/layers/trackLayers.ts b/apps/web/src/features/trackReplay/layers/trackLayers.ts new file mode 100644 index 0000000..206e3d0 --- /dev/null +++ b/apps/web/src/features/trackReplay/layers/trackLayers.ts @@ -0,0 +1,200 @@ +import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../widgets/map3d/constants'; +import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache'; +import { getShipKindColor } from '../lib/adapters'; +import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types'; + +export const TRACK_REPLAY_LAYER_IDS = { + PATH: 'track-replay-path', + POINTS: 'track-replay-points', + VIRTUAL_SHIP: 'track-replay-virtual-ship', + VIRTUAL_LABEL: 'track-replay-virtual-label', + TRAIL: 'track-replay-trail', +} as const; + +interface PathData { + vesselId: string; + path: [number, number][]; + color: [number, number, number, number]; +} + +interface PointData { + vesselId: string; + position: [number, number]; + color: [number, number, number, number]; + timestamp: number; + speed: number; + index: number; +} + +const MAX_POINTS_PER_TRACK = 800; + +export function createStaticTrackLayers(options: { + tracks: ProcessedTrack[]; + showPoints: boolean; + highlightedVesselId?: string | null; + onPathHover?: (vesselId: string | null) => void; +}): Layer[] { + const { tracks, showPoints, highlightedVesselId, onPathHover } = options; + const layers: Layer[] = []; + if (!tracks || tracks.length === 0) return layers; + + const pathData: PathData[] = tracks.map((track) => ({ + vesselId: track.vesselId, + path: track.geometry, + color: getShipKindColor(track.shipKindCode), + })); + + layers.push( + new PathLayer({ + id: TRACK_REPLAY_LAYER_IDS.PATH, + data: pathData, + getPath: (d) => d.path, + getColor: (d) => + highlightedVesselId && highlightedVesselId === d.vesselId + ? [255, 255, 0, 255] + : [d.color[0], d.color[1], d.color[2], 235], + getWidth: (d) => (highlightedVesselId && highlightedVesselId === d.vesselId ? 5 : 3), + widthUnits: 'pixels', + widthMinPixels: 1, + widthMaxPixels: 6, + parameters: DEPTH_DISABLED_PARAMS, + jointRounded: true, + capRounded: true, + pickable: true, + onHover: (info: PickingInfo) => { + onPathHover?.(info.object?.vesselId ?? null); + }, + updateTriggers: { + getColor: [highlightedVesselId], + getWidth: [highlightedVesselId], + }, + }), + ); + + if (showPoints) { + const pointData: PointData[] = []; + + for (const track of tracks) { + const color = getShipKindColor(track.shipKindCode); + const len = track.geometry.length; + if (len <= MAX_POINTS_PER_TRACK) { + for (let i = 0; i < len; i++) { + pointData.push({ + vesselId: track.vesselId, + position: track.geometry[i], + color, + timestamp: track.timestampsMs[i] || 0, + speed: track.speeds[i] || 0, + index: i, + }); + } + } else { + const step = len / MAX_POINTS_PER_TRACK; + for (let i = 0; i < MAX_POINTS_PER_TRACK; i++) { + const idx = Math.min(Math.floor(i * step), len - 1); + pointData.push({ + vesselId: track.vesselId, + position: track.geometry[idx], + color, + timestamp: track.timestampsMs[idx] || 0, + speed: track.speeds[idx] || 0, + index: idx, + }); + } + } + } + + layers.push( + new ScatterplotLayer({ + id: TRACK_REPLAY_LAYER_IDS.POINTS, + data: pointData, + getPosition: (d) => d.position, + getFillColor: (d) => d.color, + getRadius: 3, + radiusUnits: 'pixels', + radiusMinPixels: 2, + radiusMaxPixels: 5, + parameters: DEPTH_DISABLED_PARAMS, + pickable: false, + }), + ); + } + + return layers; +} + +export function createDynamicTrackLayers(options: { + currentPositions: CurrentVesselPosition[]; + showVirtualShip: boolean; + showLabels: boolean; + onIconHover?: (position: CurrentVesselPosition | null, x: number, y: number) => void; + onPathHover?: (vesselId: string | null) => void; +}): Layer[] { + const { currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover } = options; + const layers: Layer[] = []; + + if (!currentPositions || currentPositions.length === 0) return layers; + + if (showVirtualShip) { + layers.push( + new IconLayer({ + id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP, + data: currentPositions, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => d.position, + getSize: 22, + sizeUnits: 'pixels', + getAngle: (d) => -d.heading, + getColor: (d) => { + const base = getShipKindColor(d.shipKindCode); + return [base[0], base[1], base[2], 245] as [number, number, number, number]; + }, + parameters: DEPTH_DISABLED_PARAMS, + pickable: true, + onHover: (info: PickingInfo) => { + if (info.object) { + onPathHover?.(info.object.vesselId); + onIconHover?.(info.object, info.x, info.y); + } else { + onPathHover?.(null); + onIconHover?.(null, 0, 0); + } + }, + }), + ); + } + + if (showLabels) { + const labelData = currentPositions.filter((position) => (position.shipName || '').trim().length > 0); + if (labelData.length > 0) { + layers.push( + new TextLayer({ + id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_LABEL, + data: labelData, + getPosition: (d) => d.position, + getText: (d) => d.shipName, + getColor: [226, 232, 240, 240], + getSize: 11, + getTextAnchor: 'start', + getAlignmentBaseline: 'center', + getPixelOffset: [14, 0], + fontFamily: 'Malgun Gothic, Arial, sans-serif', + outlineColor: [2, 6, 23, 220], + outlineWidth: 2, + parameters: DEPTH_DISABLED_PARAMS, + pickable: false, + }), + ); + } + } + + return layers; +} + +export function isTrackReplayLayerId(id: unknown): boolean { + return typeof id === 'string' && id.startsWith('track-replay-'); +} diff --git a/apps/web/src/features/trackReplay/lib/adapters.ts b/apps/web/src/features/trackReplay/lib/adapters.ts new file mode 100644 index 0000000..b88b23a --- /dev/null +++ b/apps/web/src/features/trackReplay/lib/adapters.ts @@ -0,0 +1,159 @@ +import type { TrackPoint } from '../../../entities/vesselTrack/model/types'; +import type { ProcessedTrack, TrackStats } from '../model/track.types'; + +const DEFAULT_SHIP_KIND = '000027'; +const DEFAULT_SIGNAL_SOURCE = '000001'; +const EPSILON_DISTANCE = 1e-10; + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function normalizeTrackTimestampMs(value: string | number | undefined | null): number { + if (typeof value === 'number') { + return value < 1e12 ? value * 1000 : value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + if (/^\d{10,}$/.test(value)) { + const asNum = Number(value); + return asNum < 1e12 ? asNum * 1000 : asNum; + } + + const parsed = new Date(value).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + + return Date.now(); +} + +function calculateStats(points: TrackPoint[]): TrackStats { + let maxSpeed = 0; + let speedSum = 0; + + for (const point of points) { + const speed = Number.isFinite(point.sog) ? point.sog : 0; + maxSpeed = Math.max(maxSpeed, speed); + speedSum += speed; + } + + return { + totalDistanceNm: 0, + avgSpeed: points.length > 0 ? speedSum / points.length : 0, + maxSpeed, + pointCount: points.length, + }; +} + +export function convertLegacyTrackPointsToProcessedTrack( + mmsi: number, + points: TrackPoint[], + hints?: { + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + sigSrcCd?: string; + }, +): ProcessedTrack | null { + const sorted = [...points].sort( + (a, b) => normalizeTrackTimestampMs(a.messageTimestamp) - normalizeTrackTimestampMs(b.messageTimestamp), + ); + + if (sorted.length === 0) return null; + + const first = sorted[0]; + const normalizedPoints = sorted + .map((point) => { + const lon = toFiniteNumber(point.lon); + const lat = toFiniteNumber(point.lat); + if (lon == null || lat == null) return null; + + const ts = normalizeTrackTimestampMs(point.messageTimestamp); + const speed = toFiniteNumber(point.sog) ?? 0; + return { + point, + lon, + lat, + ts, + speed, + }; + }) + .filter((entry): entry is NonNullable => entry != null); + + if (normalizedPoints.length === 0) return null; + + const geometry: [number, number][] = []; + const timestampsMs: number[] = []; + const speeds: number[] = []; + const statsPoints: TrackPoint[] = []; + + for (const entry of normalizedPoints) { + const lastCoord = geometry[geometry.length - 1]; + const isDuplicateCoord = + lastCoord != null && + Math.abs(lastCoord[0] - entry.lon) <= EPSILON_DISTANCE && + Math.abs(lastCoord[1] - entry.lat) <= EPSILON_DISTANCE; + const lastTs = timestampsMs[timestampsMs.length - 1]; + + // Drop exact duplicate samples to avoid zero-length/duplicate segments. + if (isDuplicateCoord && lastTs === entry.ts) continue; + + geometry.push([entry.lon, entry.lat]); + timestampsMs.push(entry.ts); + speeds.push(entry.speed); + statsPoints.push(entry.point); + } + + if (geometry.length === 0) return null; + + const stats = calculateStats(statsPoints); + + return { + vesselId: `${hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE}_${mmsi}`, + targetId: String(mmsi), + sigSrcCd: hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE, + shipName: (hints?.shipName || first.name || '').trim() || `MMSI ${mmsi}`, + shipKindCode: hints?.shipKindCode || DEFAULT_SHIP_KIND, + nationalCode: hints?.nationalCode || '', + geometry, + timestampsMs, + speeds, + stats, + }; +} + +export function getTracksTimeRange(tracks: ProcessedTrack[]): { start: number; end: number } | null { + if (tracks.length === 0) return null; + + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const track of tracks) { + if (track.timestampsMs.length === 0) continue; + min = Math.min(min, track.timestampsMs[0]); + max = Math.max(max, track.timestampsMs[track.timestampsMs.length - 1]); + } + + if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return null; + return { start: min, end: max }; +} + +export function getShipKindColor(shipKindCode: string): [number, number, number, number] { + const colors: Record = { + '000020': [25, 116, 25, 180], + '000021': [0, 41, 255, 180], + '000022': [176, 42, 42, 180], + '000023': [255, 139, 54, 180], + '000024': [255, 0, 0, 180], + '000025': [92, 30, 224, 180], + '000027': [255, 135, 207, 180], + '000028': [232, 95, 27, 180], + }; + + return colors[shipKindCode] || colors['000027']; +} diff --git a/apps/web/src/features/trackReplay/lib/interpolate.ts b/apps/web/src/features/trackReplay/lib/interpolate.ts new file mode 100644 index 0000000..de3cc6a --- /dev/null +++ b/apps/web/src/features/trackReplay/lib/interpolate.ts @@ -0,0 +1,91 @@ +import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types'; + +function calculateHeading(from: [number, number], to: [number, number]): number { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + let angle = (Math.atan2(dx, dy) * 180) / Math.PI; + if (angle < 0) angle += 360; + return angle; +} + +function interpolate( + from: [number, number], + to: [number, number], + fromTs: number, + toTs: number, + currentTs: number, +): [number, number] { + if (toTs <= fromTs) return from; + if (currentTs <= fromTs) return from; + if (currentTs >= toTs) return to; + + const ratio = (currentTs - fromTs) / (toTs - fromTs); + return [ + from[0] + (to[0] - from[0]) * ratio, + from[1] + (to[1] - from[1]) * ratio, + ]; +} + +export function getCurrentPosition(track: ProcessedTrack, currentTime: number): CurrentVesselPosition | null { + const len = track.timestampsMs.length; + if (len === 0 || track.geometry.length === 0) return null; + + const firstTime = track.timestampsMs[0]; + const lastTime = track.timestampsMs[len - 1]; + if (currentTime < firstTime || currentTime > lastTime) return null; + + if (len === 1) { + return { + vesselId: track.vesselId, + targetId: track.targetId, + sigSrcCd: track.sigSrcCd, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + nationalCode: track.nationalCode, + position: track.geometry[0], + heading: 0, + speed: track.speeds[0] || 0, + timestamp: firstTime, + }; + } + + let hi = len - 1; + let lo = 0; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + if (track.timestampsMs[mid] <= currentTime) lo = mid + 1; + else hi = mid - 1; + } + + const idx = Math.max(0, Math.min(len - 2, hi)); + const from = track.geometry[idx]; + const to = track.geometry[idx + 1]; + const fromTs = track.timestampsMs[idx]; + const toTs = track.timestampsMs[idx + 1]; + + const position = interpolate(from, to, fromTs, toTs, currentTime); + const heading = calculateHeading(from, to); + const speed = track.speeds[idx] || 0; + + return { + vesselId: track.vesselId, + targetId: track.targetId, + sigSrcCd: track.sigSrcCd, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + nationalCode: track.nationalCode, + position, + heading, + speed, + timestamp: currentTime, + }; +} + +export function getCurrentPositions(tracks: ProcessedTrack[], currentTime: number): CurrentVesselPosition[] { + const out: CurrentVesselPosition[] = []; + for (const track of tracks) { + const pos = getCurrentPosition(track, currentTime); + if (pos) out.push(pos); + } + return out; +} diff --git a/apps/web/src/features/trackReplay/model/track.types.ts b/apps/web/src/features/trackReplay/model/track.types.ts new file mode 100644 index 0000000..83c5746 --- /dev/null +++ b/apps/web/src/features/trackReplay/model/track.types.ts @@ -0,0 +1,45 @@ +export type LonLat = [number, number]; + +export interface TrackStats { + totalDistanceNm: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; +} + +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + geometry: LonLat[]; + timestampsMs: number[]; + speeds: number[]; + stats: TrackStats; +} + +export interface CurrentVesselPosition { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + position: LonLat; + heading: number; + speed: number; + timestamp: number; +} + +export interface TrackQueryRequest { + mmsi: number; + minutes: number; +} + +export interface ReplayStreamQueryRequest { + startTime: string; + endTime: string; + vessels: Array<{ sigSrcCd: string; targetId: string }>; +} diff --git a/apps/web/src/features/trackReplay/services/replayStreamService.ts b/apps/web/src/features/trackReplay/services/replayStreamService.ts new file mode 100644 index 0000000..e5c6d15 --- /dev/null +++ b/apps/web/src/features/trackReplay/services/replayStreamService.ts @@ -0,0 +1,35 @@ +import type { ReplayStreamQueryRequest } from '../model/track.types'; + +export interface ReplayStreamHandlers { + onConnected?: () => void; + onDisconnected?: () => void; + onError?: (error: Error) => void; + onChunk?: (chunk: unknown) => void; + onCompleted?: () => void; +} + +class ReplayStreamService { + private readonly enabled = String(import.meta.env.VITE_TRACKING_WS_ENABLED || 'false') === 'true'; + + async connect(handlers?: ReplayStreamHandlers): Promise { + void handlers; + if (!this.enabled) return false; + return false; + } + + async startQuery(request: ReplayStreamQueryRequest): Promise { + void request; + if (!this.enabled) return false; + return false; + } + + async cancel(): Promise { + if (!this.enabled) return; + } + + disconnect(): void { + if (!this.enabled) return; + } +} + +export const replayStreamService = new ReplayStreamService(); diff --git a/apps/web/src/features/trackReplay/services/trackQueryService.ts b/apps/web/src/features/trackReplay/services/trackQueryService.ts new file mode 100644 index 0000000..8435151 --- /dev/null +++ b/apps/web/src/features/trackReplay/services/trackQueryService.ts @@ -0,0 +1,162 @@ +import { fetchVesselTrack } from '../../../entities/vesselTrack/api/fetchTrack'; +import { convertLegacyTrackPointsToProcessedTrack } from '../lib/adapters'; +import type { ProcessedTrack } from '../model/track.types'; + +type QueryTrackByMmsiParams = { + mmsi: number; + minutes: number; + shipNameHint?: string; + shipKindCodeHint?: string; + nationalCodeHint?: string; +}; + +type V2TrackResponse = { + vesselId?: string; + targetId?: string; + sigSrcCd?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + geometry?: [number, number][]; + timestamps?: Array; + speeds?: number[]; + totalDistance?: number; + avgSpeed?: number; + maxSpeed?: number; + pointCount?: number; +}; + +function normalizeTimestampMs(value: string | number): number { + if (typeof value === 'number') return value < 1e12 ? value * 1000 : value; + if (/^\d{10,}$/.test(value)) { + const asNum = Number(value); + return asNum < 1e12 ? asNum * 1000 : asNum; + } + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] { + const out: ProcessedTrack[] = []; + + for (const row of rows) { + if (!row.geometry || row.geometry.length === 0) continue; + const timestamps = Array.isArray(row.timestamps) ? row.timestamps : []; + const timestampsMs = timestamps.map((ts) => normalizeTimestampMs(ts)); + + const sortedIndices = timestampsMs + .map((_, idx) => idx) + .sort((a, b) => timestampsMs[a] - timestampsMs[b]); + + const geometry: [number, number][] = []; + const sortedTimes: number[] = []; + const speeds: number[] = []; + for (const idx of sortedIndices) { + const coord = row.geometry?.[idx]; + if (!Array.isArray(coord) || coord.length !== 2) continue; + const nLon = toFiniteNumber(coord[0]); + const nLat = toFiniteNumber(coord[1]); + if (nLon == null || nLat == null) continue; + + geometry.push([nLon, nLat]); + sortedTimes.push(timestampsMs[idx]); + speeds.push(toFiniteNumber(row.speeds?.[idx]) ?? 0); + } + + if (geometry.length === 0) continue; + + const targetId = row.targetId || row.vesselId || ''; + const sigSrcCd = row.sigSrcCd || '000001'; + + out.push({ + vesselId: row.vesselId || `${sigSrcCd}_${targetId}`, + targetId, + sigSrcCd, + shipName: (row.shipName || '').trim() || targetId, + shipKindCode: row.shipKindCode || '000027', + nationalCode: row.nationalCode || '', + geometry, + timestampsMs: sortedTimes, + speeds, + stats: { + totalDistanceNm: row.totalDistance || 0, + avgSpeed: row.avgSpeed || 0, + maxSpeed: row.maxSpeed || 0, + pointCount: row.pointCount || geometry.length, + }, + }); + } + + return out; +} + +async function queryLegacyTrack(params: QueryTrackByMmsiParams): Promise { + const response = await fetchVesselTrack(params.mmsi, params.minutes); + if (!response.success || response.data.length === 0) return []; + + const converted = convertLegacyTrackPointsToProcessedTrack(params.mmsi, response.data, { + shipName: params.shipNameHint, + shipKindCode: params.shipKindCodeHint, + nationalCode: params.nationalCodeHint, + }); + + return converted ? [converted] : []; +} + +async function queryV2Track(params: QueryTrackByMmsiParams): Promise { + const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '').trim(); + if (!base) { + return queryLegacyTrack(params); + } + + const end = new Date(); + const start = new Date(end.getTime() - params.minutes * 60_000); + + const requestBody = { + startTime: start.toISOString().slice(0, 19), + endTime: end.toISOString().slice(0, 19), + vessels: [{ sigSrcCd: '000001', targetId: String(params.mmsi) }], + isIntegration: '0', + }; + + const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!res.ok) { + return queryLegacyTrack(params); + } + + const json = (await res.json()) as unknown; + const rows = Array.isArray(json) + ? (json as V2TrackResponse[]) + : Array.isArray((json as { data?: unknown }).data) + ? ((json as { data: V2TrackResponse[] }).data) + : []; + + const converted = convertV2Tracks(rows); + if (converted.length > 0) return converted; + return queryLegacyTrack(params); +} + +export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise { + const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase(); + + if (mode === 'v2') { + return queryV2Track(params); + } + + return queryLegacyTrack(params); +} diff --git a/apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts b/apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts new file mode 100644 index 0000000..1b0add7 --- /dev/null +++ b/apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts @@ -0,0 +1,162 @@ +import { create } from 'zustand'; + +interface TrackPlaybackState { + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + loop: boolean; + loopStart: number; + loopEnd: number; + + play: () => void; + pause: () => void; + stop: () => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + toggleLoop: () => void; + setLoopSection: (start: number, end: number) => void; + setTimeRange: (start: number, end: number) => void; + syncToRangeStart: () => void; + reset: () => void; +} + +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; + +function clearAnimation(): void { + if (animationFrameId != null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; +} + +export const useTrackPlaybackStore = create()((set, get) => { + const animate = (): void => { + const state = get(); + if (!state.isPlaying) return; + + const now = performance.now(); + if (lastFrameTime == null) { + lastFrameTime = now; + } + + const delta = now - lastFrameTime; + lastFrameTime = now; + + const advanceMs = delta * state.playbackSpeed; + let nextTime = state.currentTime + advanceMs; + const rangeStart = state.loop ? state.loopStart : state.startTime; + const rangeEnd = state.loop ? state.loopEnd : state.endTime; + + if (nextTime >= rangeEnd) { + if (state.loop) { + nextTime = rangeStart; + } else { + nextTime = state.endTime; + set({ currentTime: nextTime, isPlaying: false }); + clearAnimation(); + return; + } + } + + set({ currentTime: nextTime }); + animationFrameId = requestAnimationFrame(animate); + }; + + return { + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 100, + loop: false, + loopStart: 0, + loopEnd: 0, + + play: () => { + const state = get(); + if (state.endTime <= state.startTime) return; + + if (state.currentTime < state.startTime || state.currentTime > state.endTime) { + set({ currentTime: state.startTime }); + } + + set({ isPlaying: true }); + clearAnimation(); + animationFrameId = requestAnimationFrame(animate); + }, + + pause: () => { + clearAnimation(); + set({ isPlaying: false }); + }, + + stop: () => { + clearAnimation(); + set((state) => ({ isPlaying: false, currentTime: state.startTime })); + }, + + setCurrentTime: (time: number) => { + const { startTime, endTime } = get(); + const clamped = Math.max(startTime, Math.min(endTime, time)); + set({ currentTime: clamped }); + }, + + setPlaybackSpeed: (speed: number) => { + const normalized = Number.isFinite(speed) && speed > 0 ? speed : 1; + set({ playbackSpeed: normalized }); + }, + + toggleLoop: () => { + set((state) => ({ loop: !state.loop })); + }, + + setLoopSection: (start: number, end: number) => { + const state = get(); + const clampedStart = Math.max(state.startTime, Math.min(end, start)); + const clampedEnd = Math.min(state.endTime, Math.max(start, end)); + set({ loopStart: clampedStart, loopEnd: clampedEnd }); + }, + + setTimeRange: (start: number, end: number) => { + const safeStart = Number.isFinite(start) ? start : 0; + const safeEnd = Number.isFinite(end) ? end : safeStart; + clearAnimation(); + set({ + isPlaying: false, + startTime: safeStart, + endTime: safeEnd, + currentTime: safeStart, + loopStart: safeStart, + loopEnd: safeEnd, + }); + }, + + syncToRangeStart: () => { + clearAnimation(); + set((state) => ({ + isPlaying: false, + currentTime: state.startTime, + })); + }, + + reset: () => { + clearAnimation(); + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 100, + loop: false, + loopStart: 0, + loopEnd: 0, + }); + }, + }; +}); + +export const TRACK_PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 25, 50, 100] as const; diff --git a/apps/web/src/features/trackReplay/stores/trackQueryStore.ts b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts new file mode 100644 index 0000000..0a29cd7 --- /dev/null +++ b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts @@ -0,0 +1,210 @@ +import { create } from 'zustand'; +import { getTracksTimeRange } from '../lib/adapters'; +import type { ProcessedTrack } from '../model/track.types'; +import { useTrackPlaybackStore } from './trackPlaybackStore'; + +export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error'; + +interface TrackQueryState { + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + highlightedVesselId: string | null; + isLoading: boolean; + error: string | null; + queryState: TrackQueryStatus; + renderEpoch: number; + lastQueryKey: string | null; + showPoints: boolean; + showVirtualShip: boolean; + showLabels: boolean; + showTrail: boolean; + hideLiveShips: boolean; + + beginQuery: (queryKey: string) => void; + applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void; + applyQueryError: (error: string, queryKey?: string | null) => void; + closeQuery: () => void; + + setTracks: (tracks: ProcessedTrack[]) => void; + clearTracks: () => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setHighlightedVesselId: (vesselId: string | null) => void; + setShowPoints: (show: boolean) => void; + setShowVirtualShip: (show: boolean) => void; + setShowLabels: (show: boolean) => void; + setShowTrail: (show: boolean) => void; + setHideLiveShips: (hide: boolean) => void; + toggleVesselEnabled: (vesselId: string) => void; + getEnabledTracks: () => ProcessedTrack[]; + reset: () => void; +} + +export const useTrackQueryStore = create()((set, get) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: false, + error: null, + queryState: 'idle', + renderEpoch: 0, + lastQueryKey: null, + showPoints: true, + showVirtualShip: true, + showLabels: true, + showTrail: true, + hideLiveShips: false, + + beginQuery: (queryKey: string) => { + useTrackPlaybackStore.getState().reset(); + set((state) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: true, + error: null, + queryState: 'loading', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: queryKey, + hideLiveShips: false, + })); + }, + + applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => { + const currentQueryKey = get().lastQueryKey; + if (queryKey != null && queryKey !== currentQueryKey) { + // Ignore stale async responses from an older query. + return; + } + + const range = getTracksTimeRange(tracks); + const playback = useTrackPlaybackStore.getState(); + + if (range) { + playback.setTimeRange(range.start, range.end); + playback.syncToRangeStart(); + playback.setPlaybackSpeed(100); + } else { + playback.reset(); + } + + set((state) => ({ + tracks, + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: false, + error: null, + queryState: 'ready', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: queryKey ?? state.lastQueryKey, + })); + + if (range) { + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + useTrackPlaybackStore.getState().play(); + }); + } else { + useTrackPlaybackStore.getState().play(); + } + } + }, + + applyQueryError: (error: string, queryKey?: string | null) => { + const currentQueryKey = get().lastQueryKey; + if (queryKey != null && queryKey !== currentQueryKey) { + // Ignore stale async errors from an older query. + return; + } + + useTrackPlaybackStore.getState().reset(); + set((state) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: false, + error, + queryState: 'error', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: queryKey ?? state.lastQueryKey, + hideLiveShips: false, + })); + }, + + closeQuery: () => { + useTrackPlaybackStore.getState().reset(); + set((state) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: false, + error: null, + queryState: 'idle', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: null, + hideLiveShips: false, + })); + }, + + setTracks: (tracks: ProcessedTrack[]) => { + get().applyTracksSuccess(tracks, get().lastQueryKey); + }, + + clearTracks: () => { + get().closeQuery(); + }, + + setLoading: (loading: boolean) => + set((state) => ({ + isLoading: loading, + queryState: loading ? 'loading' : state.error ? 'error' : state.tracks.length > 0 ? 'ready' : 'idle', + })), + + setError: (error: string | null) => + set((state) => ({ + error, + queryState: error ? 'error' : state.isLoading ? 'loading' : state.tracks.length > 0 ? 'ready' : 'idle', + })), + + setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }), + setShowPoints: (show: boolean) => set({ showPoints: show }), + setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }), + setShowLabels: (show: boolean) => set({ showLabels: show }), + setShowTrail: (show: boolean) => set({ showTrail: show }), + setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }), + + toggleVesselEnabled: (vesselId: string) => { + const next = new Set(get().disabledVesselIds); + if (next.has(vesselId)) next.delete(vesselId); + else next.add(vesselId); + set((state) => ({ + disabledVesselIds: next, + renderEpoch: state.renderEpoch + 1, + })); + }, + + getEnabledTracks: () => { + const { tracks, disabledVesselIds } = get(); + if (disabledVesselIds.size === 0) return tracks; + return tracks.filter((track) => !disabledVesselIds.has(track.vesselId)); + }, + + reset: () => { + useTrackPlaybackStore.getState().reset(); + set((state) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: false, + error: null, + queryState: 'idle', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: null, + showPoints: true, + showVirtualShip: true, + showLabels: true, + showTrail: true, + hideLiveShips: false, + })); + }, +})); diff --git a/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts b/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts new file mode 100644 index 0000000..2262708 --- /dev/null +++ b/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts @@ -0,0 +1,415 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import { config as maptilerConfig } from '@maptiler/sdk'; +import { + WindLayer, + TemperatureLayer, + PrecipitationLayer, + PressureLayer, + RadarLayer, + ColorRamp, +} from '@maptiler/weather'; +import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore'; + +/** 6종 기상 레이어 ID */ +export type WeatherLayerId = + | 'wind' + | 'temperature' + | 'precipitation' + | 'pressure' + | 'radar' + | 'clouds'; + +export interface WeatherLayerMeta { + id: WeatherLayerId; + label: string; + icon: string; +} + +export const WEATHER_LAYERS: WeatherLayerMeta[] = [ + { id: 'wind', label: '바람', icon: '💨' }, + { id: 'temperature', label: '기온', icon: '🌡' }, + { id: 'precipitation', label: '강수', icon: '🌧' }, + { id: 'pressure', label: '기압', icon: '◎' }, + { id: 'radar', label: '레이더', icon: '📡' }, + { id: 'clouds', label: '구름', icon: '☁' }, +]; + +const LAYER_ID_PREFIX = 'maptiler-weather-'; + +/** 한중일 + 남중국해 영역 [west, south, east, north] */ +const TILE_BOUNDS: [number, number, number, number] = [100, 10, 150, 50]; + +type AnyWeatherLayer = WindLayer | TemperatureLayer | PrecipitationLayer | PressureLayer | RadarLayer; + +const DEFAULT_ENABLED: Record = { + wind: false, + temperature: false, + precipitation: false, + pressure: false, + radar: false, + clouds: false, +}; + +/** 각 레이어별 범례 정보 */ +export interface LegendInfo { + label: string; + unit: string; + colorRamp: ColorRamp; +} + +export const LEGEND_META: Record = { + wind: { label: '풍속', unit: 'm/s', colorRamp: ColorRamp.builtin.WIND_ROCKET }, + temperature: { label: '기온', unit: '°C', colorRamp: ColorRamp.builtin.TEMPERATURE_3 }, + precipitation: { label: '강수량', unit: 'mm/h', colorRamp: ColorRamp.builtin.PRECIPITATION }, + pressure: { label: '기압', unit: 'hPa', colorRamp: ColorRamp.builtin.PRESSURE_2 }, + radar: { label: '레이더', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR }, + clouds: { label: '구름', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR_CLOUD }, +}; + +/** + * 배속 옵션. + * animateByFactor(value) → 실시간 1초당 value초 진행. + * 3600 = 1시간/초, 7200 = 2시간/초 ... + */ +export const SPEED_OPTIONS = [ + { value: 1800, label: '30분/초' }, + { value: 3600, label: '1시간/초' }, + { value: 7200, label: '2시간/초' }, + { value: 14400, label: '4시간/초' }, +]; + +// bounds는 TileLayerOptions에 정의되나 개별 레이어 생성자 타입에 누락되어 as any 필요 +/* eslint-disable @typescript-eslint/no-explicit-any */ +function createLayerInstance(layerId: WeatherLayerId, opacity: number): AnyWeatherLayer { + const id = `${LAYER_ID_PREFIX}${layerId}`; + const opts = { id, opacity, bounds: TILE_BOUNDS }; + switch (layerId) { + case 'wind': + return new WindLayer({ + ...opts, + colorramp: ColorRamp.builtin.WIND_ROCKET, + speed: 0.001, + fadeFactor: 0.03, + maxAmount: 256, + density: 4, + fastColor: [255, 100, 80, 230], + } as any); + case 'temperature': + return new TemperatureLayer({ + ...opts, + colorramp: ColorRamp.builtin.TEMPERATURE_3, + } as any); + case 'precipitation': + return new PrecipitationLayer({ + ...opts, + colorramp: ColorRamp.builtin.PRECIPITATION, + } as any); + case 'pressure': + return new PressureLayer({ + ...opts, + colorramp: ColorRamp.builtin.PRESSURE_2, + } as any); + case 'radar': + return new RadarLayer({ + ...opts, + colorramp: ColorRamp.builtin.RADAR, + } as any); + case 'clouds': + return new RadarLayer({ + ...opts, + colorramp: ColorRamp.builtin.RADAR_CLOUD, + } as any); + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** 타임라인 step 간격 (3시간 = 10 800초) */ +const STEP_INTERVAL_SEC = 3 * 3600; + +/** start~end 를 STEP_INTERVAL_SEC 단위로 나눈 epoch-초 배열 */ +function buildSteps(startSec: number, endSec: number): number[] { + const steps: number[] = []; + for (let t = startSec; t <= endSec; t += STEP_INTERVAL_SEC) { + steps.push(t); + } + // 마지막 step이 endSec 와 다르면 보정 + if (steps.length > 0 && steps[steps.length - 1] < endSec) { + steps.push(endSec); + } + return steps; +} + +export interface WeatherOverlayState { + enabled: Record; + activeLayerId: WeatherLayerId | null; + opacity: number; + isPlaying: boolean; + animationSpeed: number; + currentTime: Date | null; + startTime: Date | null; + endTime: Date | null; + /** step epoch(초) 배열 — 타임라인 눈금 */ + steps: number[]; + isReady: boolean; +} + +export interface WeatherOverlayActions { + toggleLayer: (id: WeatherLayerId) => void; + setOpacity: (v: number) => void; + play: () => void; + pause: () => void; + setSpeed: (factor: number) => void; + /** epoch 초 단위로 seek (SDK 내부 시간 = 초) */ + seekTo: (epochSec: number) => void; +} + +/** + * MapTiler Weather SDK 6종 오버레이 레이어를 관리하는 훅. + * map 인스턴스가 null이면 대기, 값이 설정되면 레이어 추가/제거 활성화. + */ +export function useWeatherOverlay( + map: maplibregl.Map | null, +): WeatherOverlayState & WeatherOverlayActions { + const [enabled, setEnabled] = useState>({ ...DEFAULT_ENABLED }); + + const [opacity, setOpacityState] = useState(0.6); + const [isPlaying, setIsPlaying] = useState(false); + const [animationSpeed, setAnimationSpeed] = useState(3600); + const [currentTime, setCurrentTime] = useState(null); + const [startTime, setStartTime] = useState(null); + const [endTime, setEndTime] = useState(null); + const [isReady, setIsReady] = useState(false); + const [steps, setSteps] = useState([]); + + const layerInstancesRef = useRef>(new Map()); + const apiKeySetRef = useRef(false); + /** SDK raw 시간 범위 (초 단위) */ + const animRangeRef = useRef<{ start: number; end: number } | null>(null); + + // 레이어 add effect 안의 async 콜백에서 최신 isPlaying/animationSpeed를 읽기 위한 ref + const isPlayingRef = useRef(isPlaying); + isPlayingRef.current = isPlaying; + const animationSpeedRef = useRef(animationSpeed); + animationSpeedRef.current = animationSpeed; + + // API key 설정 + ServiceWorker 등록 + useEffect(() => { + if (apiKeySetRef.current) return; + const key = getMapTilerKey(); + if (key) { + maptilerConfig.apiKey = key; + apiKeySetRef.current = true; + } + // 타일 캐시 SW 등록 (실패해도 무시 — 캐시 없이도 동작) + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw-weather-cache.js').catch(() => {}); + } + }, []); + + // maplibre-gl Map에 MapTiler SDK 전용 메서드/프로퍼티 패치 + useEffect(() => { + if (!map) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const m = map as any; + if (typeof m.getSdkConfig === 'function') return; + m.getSdkConfig = () => maptilerConfig; + m.getMaptilerSessionId = () => ''; + m.isGlobeProjection = () => map.getProjection?.()?.type === 'globe'; + if (!m.telemetry) { + m.telemetry = { registerModule: () => {} }; + } + }, [map]); + + // enabled 변경 시 레이어 추가/제거 + useEffect(() => { + if (!map) return; + if (!apiKeySetRef.current) return; + + const instances = layerInstancesRef.current; + + for (const meta of WEATHER_LAYERS) { + const isOn = enabled[meta.id]; + const existing = instances.get(meta.id); + + if (isOn && !existing) { + const layer = createLayerInstance(meta.id, opacity); + instances.set(meta.id, layer); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.addLayer(layer as any); + + // 소스가 준비되면 시간 범위 설정 + 재생 상태 적용 + layer.onSourceReadyAsync().then(() => { + if (!instances.has(meta.id)) return; + + // SDK 내부 시간은 epoch 초 단위 + const rawStart = layer.getAnimationStart(); + const rawEnd = layer.getAnimationEnd(); + animRangeRef.current = { start: rawStart, end: rawEnd }; + + setStartTime(layer.getAnimationStartDate()); + setEndTime(layer.getAnimationEndDate()); + setSteps(buildSteps(rawStart, rawEnd)); + + // 시작 시간으로 초기화 (초 단위 전달) + layer.setAnimationTime(rawStart); + setCurrentTime(layer.getAnimationStartDate()); + setIsReady(true); + + // 재생 중이었다면 새 레이어에도 재생 적용 + if (isPlayingRef.current) { + layer.animateByFactor(animationSpeedRef.current); + } + }); + } catch (e) { + if (import.meta.env.DEV) { + console.warn(`[WeatherOverlay] Failed to add layer ${meta.id}:`, e); + } + instances.delete(meta.id); + } + } else if (!isOn && existing) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (map.getLayer((existing as any).id)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.removeLayer((existing as any).id); + } + } catch { + // ignore + } + instances.delete(meta.id); + } + } + + if (instances.size === 0) { + setIsReady(false); + setStartTime(null); + setEndTime(null); + setCurrentTime(null); + setSteps([]); + animRangeRef.current = null; + } + }, [enabled, map, opacity]); + + // opacity 변경 시 기존 레이어에 반영 + useEffect(() => { + for (const layer of layerInstancesRef.current.values()) { + layer.setOpacity(opacity); + } + }, [opacity]); + + // 애니메이션 상태 동기화 + useEffect(() => { + for (const layer of layerInstancesRef.current.values()) { + if (isPlaying) { + layer.animateByFactor(animationSpeed); + } else { + layer.animateByFactor(0); + } + } + }, [isPlaying, animationSpeed]); + + // 재생 중 rAF 폴링으로 currentTime 동기화 (~4fps) + useEffect(() => { + if (!isPlaying) return; + const instances = layerInstancesRef.current; + if (instances.size === 0) return; + let rafId: number; + let lastUpdate = 0; + const poll = () => { + const now = performance.now(); + if (now - lastUpdate > 250) { + lastUpdate = now; + const first = instances.values().next().value; + if (first) { + setCurrentTime(first.getAnimationTimeDate()); + } + } + rafId = requestAnimationFrame(poll); + }; + rafId = requestAnimationFrame(poll); + return () => cancelAnimationFrame(rafId); + }, [isPlaying]); + + // cleanup on map change or unmount + useEffect(() => { + return () => { + for (const [id, layer] of layerInstancesRef.current) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (map?.getLayer((layer as any).id)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.removeLayer((layer as any).id); + } + } catch { + // ignore + } + void id; + } + layerInstancesRef.current.clear(); + }; + }, [map]); + + // 라디오 버튼 동작: 하나만 활성, 다시 누르면 전부 off + const toggleLayer = useCallback((id: WeatherLayerId) => { + setEnabled((prev) => { + const next = { ...DEFAULT_ENABLED }; + if (!prev[id]) next[id] = true; + return next; + }); + // 레이어 전환 시 isReady 리셋 (새 소스 로딩 대기) + setIsReady(false); + }, []); + + const setOpacity = useCallback((v: number) => { + setOpacityState(Math.max(0, Math.min(1, v))); + }, []); + + const play = useCallback(() => setIsPlaying(true), []); + const pause = useCallback(() => setIsPlaying(false), []); + + const setSpeed = useCallback((factor: number) => { + setAnimationSpeed(factor); + }, []); + + /** epochSec = SDK 내부 초 단위 시간 */ + const seekTo = useCallback((epochSec: number) => { + for (const layer of layerInstancesRef.current.values()) { + layer.setAnimationTime(epochSec); + } + setCurrentTime(new Date(epochSec * 1000)); + // SDK CustomLayerInterface.render() 가 호출되어야 타일이 실제 갱신됨 + // 여러 프레임에 걸쳐 repaint 트리거 + if (map) { + let count = 0; + const kick = () => { + map.triggerRepaint(); + if (++count < 6) requestAnimationFrame(kick); + }; + kick(); + } + }, [map]); + + const activeLayerId = (Object.keys(enabled) as WeatherLayerId[]).find((k) => enabled[k]) ?? null; + + return { + enabled, + activeLayerId, + opacity, + isPlaying, + animationSpeed, + currentTime, + startTime, + endTime, + steps, + isReady, + toggleLayer, + setOpacity, + play, + pause, + setSpeed, + seekTo, + }; +} diff --git a/apps/web/src/features/weatherOverlay/useWeatherPolling.ts b/apps/web/src/features/weatherOverlay/useWeatherPolling.ts new file mode 100644 index 0000000..ddcc3a1 --- /dev/null +++ b/apps/web/src/features/weatherOverlay/useWeatherPolling.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; +import { ZONE_META, type ZoneId } from '../../entities/zone/model/meta'; +import type { WeatherQueryPoint, WeatherSnapshot } from '../../entities/weather/model/types'; +import { fetchWeatherForPoints } from '../../entities/weather/api/fetchWeather'; +import { computeMultiPolygonCentroid } from '../../entities/weather/lib/weatherUtils'; + +const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5분 + +/** + * zones GeoJSON에서 조회 지점(centroid) 목록 생성. + * zoneId 속성이 있는 Feature만 사용. + */ +function buildQueryPoints(zones: ZonesGeoJson): WeatherQueryPoint[] { + const points: WeatherQueryPoint[] = []; + + for (const feature of zones.features) { + const zoneId = feature.properties?.zoneId as ZoneId | undefined; + if (!zoneId || !ZONE_META[zoneId]) continue; + + const meta = ZONE_META[zoneId]; + const [lon, lat] = computeMultiPolygonCentroid( + feature.geometry.coordinates as number[][][][], + ); + + points.push({ + label: `${meta.label} ${meta.name}`, + color: meta.color, + lat, + lon, + zoneId, + }); + } + + return points; +} + +export interface WeatherPollingResult { + snapshot: WeatherSnapshot | null; + isLoading: boolean; + error: string | null; + refresh: () => void; +} + +/** + * 수역 기상 데이터를 5분 간격으로 폴링하는 훅. + * zonesGeoJson이 null이면 대기 (아직 로딩 안 된 경우). + */ +export function useWeatherPolling( + zonesGeoJson: ZonesGeoJson | null, +): WeatherPollingResult { + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const queryPoints = useMemo(() => { + if (!zonesGeoJson) return null; + return buildQueryPoints(zonesGeoJson); + }, [zonesGeoJson]); + + const doFetch = useCallback(async () => { + if (!queryPoints || queryPoints.length === 0) return; + + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + + setIsLoading(true); + setError(null); + + try { + const result = await fetchWeatherForPoints(queryPoints); + if (ac.signal.aborted) return; + setSnapshot(result); + } catch (e) { + if (ac.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (!ac.signal.aborted) setIsLoading(false); + } + }, [queryPoints]); + + // 마운트 + queryPoints 변경 시 즉시 1회 호출 + 5분 간격 폴링 + useEffect(() => { + if (!queryPoints || queryPoints.length === 0) return; + + void doFetch(); + const timer = setInterval(() => void doFetch(), POLL_INTERVAL_MS); + return () => { + clearInterval(timer); + abortRef.current?.abort(); + }; + }, [queryPoints, doFetch]); + + return { snapshot, isLoading, error, refresh: doFetch }; +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 20946eb..734ae11 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -27,13 +27,18 @@ 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"; import type { MapStyleSettings } from "../../features/mapSettings/types"; import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime"; +import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; +import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; +import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; +import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; +import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; +import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; +import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { buildLegacyHitMap, computeCountsByType, @@ -79,6 +84,13 @@ export function DashboardPage() { const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); + const weather = useWeatherPolling(zones); + const [mapInstance, setMapInstance] = useState(null); + const weatherOverlay = useWeatherOverlay(mapInstance); + const handleMapReady = useCallback((map: import("maplibre-gl").Map) => { + setMapInstance(map); + }, []); + const [viewBbox, setViewBbox] = useState(null); const [useViewportFilter, setUseViewportFilter] = useState(false); const [useApiBbox, setUseApiBbox] = useState(false); @@ -132,27 +144,33 @@ export function DashboardPage() { 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) => { + const trackStore = useTrackQueryStore.getState(); + const queryKey = `${mmsi}:${minutes}:${Date.now()}`; + trackStore.beginQuery(queryKey); + 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() }); + const target = targets.find((item) => item.mmsi === mmsi); + const tracks = await queryTrackByMmsi({ + mmsi, + minutes, + shipNameHint: target?.name, + }); + + if (tracks.length > 0) { + trackStore.applyTracksSuccess(tracks, queryKey); } else { - console.warn('Track: no data', res.message); + trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); } } catch (e) { - console.warn('Track fetch failed:', e); + trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); } - }, []); + }, [targets]); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, @@ -754,12 +772,21 @@ export function DashboardPage() { mapStyleSettings={mapStyleSettings} initialView={mapView} onViewStateChange={setMapView} - activeTrack={activeTrack} + activeTrack={null} trackContextMenu={trackContextMenu} onRequestTrack={handleRequestTrack} onCloseTrackMenu={handleCloseTrackMenu} onOpenTrackMenu={handleOpenTrackMenu} + onMapReady={handleMapReady} /> + + + diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2394a52..d29f136 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,9 +26,13 @@ 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 { useTrackReplayLayer } from './hooks/useTrackReplayLayer'; import { useMapStyleSettings } from './hooks/useMapStyleSettings'; import { VesselContextMenu } from './components/VesselContextMenu'; +import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter'; +import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender'; +import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; +import { useTrackReplayDeckLayers } from '../../features/trackReplay/hooks/useTrackReplayDeckLayers'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -76,14 +80,8 @@ export function Map3D({ onRequestTrack, onCloseTrackMenu, onOpenTrackMenu, + onMapReady, }: Props) { - void onHoverFleet; - void onClearFleetHover; - void onHoverMmsi; - void onClearMmsiHover; - void onHoverPair; - void onClearPairHover; - // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); const mapRef = useRef(null); @@ -200,20 +198,38 @@ export function Map3D({ ); // ── Ship data memos ────────────────────────────────────────────────── - const shipData = useMemo(() => { + const rawShipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); + const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips); + + const liveShipFeatures = useLiveShipAdapter(rawShipData, legacyHits ?? null); + const { renderedTargets: batchRenderedTargets } = useLiveShipBatchRender( + mapRef, + liveShipFeatures, + rawShipData, + mapSyncEpoch, + ); + + const shipData = useMemo( + () => (hideLiveShips ? [] : rawShipData), + [hideLiveShips, rawShipData], + ); + const shipByMmsi = useMemo(() => { const byMmsi = new Map(); - for (const t of shipData) byMmsi.set(t.mmsi, t); + for (const t of rawShipData) byMmsi.set(t.mmsi, t); return byMmsi; - }, [shipData]); + }, [rawShipData]); const shipLayerData = useMemo(() => { - if (shipData.length === 0) return shipData; - return [...shipData]; - }, [shipData]); + if (hideLiveShips) return []; + // Fallback to raw targets when batch result is temporarily empty + // (e.g. overlay update race or viewport sync delay). + if (batchRenderedTargets.length === 0) return rawShipData; + return [...batchRenderedTargets]; + }, [hideLiveShips, batchRenderedTargets, rawShipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetForShips); @@ -235,6 +251,8 @@ export function Map3D({ return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); }, [shipHighlightSet, shipLayerData]); + const trackReplayRenderState = useTrackReplayDeckLayers(); + // ── Deck hover management ──────────────────────────────────────────── const hasAuxiliarySelectModifier = useCallback( (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { @@ -294,22 +312,51 @@ export function Map3D({ ownerKey: null, vesselMmsis: [], }); + const mapDrivenMmsiHoverRef = useRef(false); + const mapDrivenPairHoverRef = useRef(false); + const mapDrivenFleetHoverRef = useRef(false); const clearMapFleetHoverState = useCallback(() => { + const prev = mapFleetHoverStateRef.current; mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); - }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + if ( + mapDrivenFleetHoverRef.current && + (prev.ownerKey != null || prev.vesselMmsis.length > 0) && + hoveredFleetOwnerKey === prev.ownerKey && + equalNumberArrays(hoveredFleetMmsiSet, prev.vesselMmsis) + ) { + onClearFleetHover?.(); + } + mapDrivenFleetHoverRef.current = false; + }, [ + setHoveredDeckFleetOwner, + setHoveredDeckFleetMmsis, + onClearFleetHover, + hoveredFleetOwnerKey, + hoveredFleetMmsiSet, + ]); const clearDeckHoverPairs = useCallback(() => { + const prev = mapDeckPairHoverRef.current; mapDeckPairHoverRef.current = []; setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - }, [setHoveredDeckPairMmsiSet]); + if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) { + onClearPairHover?.(); + } + mapDrivenPairHoverRef.current = false; + }, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]); const clearDeckHoverMmsi = useCallback(() => { + const prev = mapDeckMmsiHoverRef.current; mapDeckMmsiHoverRef.current = []; setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - }, [setHoveredDeckMmsiSet]); + if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) { + onClearMmsiHover?.(); + } + mapDrivenMmsiHoverRef.current = false; + }, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]); const scheduleDeckHoverResolve = useCallback(() => { if (deckHoverRafRef.current != null) return; @@ -335,21 +382,41 @@ export function Map3D({ const setDeckHoverMmsi = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); + const prev = mapDeckMmsiHoverRef.current; touchDeckHoverState(normalized.length > 0); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckMmsiHoverRef.current = normalized; + if (!equalNumberArrays(prev, normalized)) { + if (normalized.length > 0) { + mapDrivenMmsiHoverRef.current = true; + onHoverMmsi?.(normalized); + } else if (mapDrivenMmsiHoverRef.current && prev.length > 0) { + onClearMmsiHover?.(); + mapDrivenMmsiHoverRef.current = false; + } + } }, - [setHoveredDeckMmsiSet, touchDeckHoverState], + [setHoveredDeckMmsiSet, touchDeckHoverState, onHoverMmsi, onClearMmsiHover], ); const setDeckHoverPairs = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); + const prev = mapDeckPairHoverRef.current; touchDeckHoverState(normalized.length > 0); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckPairHoverRef.current = normalized; + if (!equalNumberArrays(prev, normalized)) { + if (normalized.length > 0) { + mapDrivenPairHoverRef.current = true; + onHoverPair?.(normalized); + } else if (mapDrivenPairHoverRef.current && prev.length > 0) { + onClearPairHover?.(); + mapDrivenPairHoverRef.current = false; + } + } }, - [setHoveredDeckPairMmsiSet, touchDeckHoverState], + [setHoveredDeckPairMmsiSet, touchDeckHoverState, onHoverPair, onClearPairHover], ); const setMapFleetHoverState = useCallback( @@ -363,8 +430,21 @@ export function Map3D({ setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + if (ownerKey != null || normalized.length > 0) { + mapDrivenFleetHoverRef.current = true; + onHoverFleet?.(ownerKey, normalized); + } else if (mapDrivenFleetHoverRef.current) { + onClearFleetHover?.(); + mapDrivenFleetHoverRef.current = false; + } }, - [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], + [ + setHoveredDeckFleetOwner, + setHoveredDeckFleetMmsis, + touchDeckHoverState, + onHoverFleet, + onClearFleetHover, + ], ); // hover RAF cleanup @@ -412,37 +492,37 @@ export function Map3D({ }, [pairLinks]); const pairLinksInteractive = useMemo(() => { - if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; - if (hoveredPairMmsiSetRef.size < 2) return []; + if ((pairLinks?.length ?? 0) === 0) return []; + if (effectiveHoveredPairMmsiSet.size < 2) return []; const links = pairLinks || []; return links.filter((link) => - hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), + effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi), ); - }, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); + }, [pairLinks, effectiveHoveredPairMmsiSet]); const pairRangesInteractive = useMemo(() => { - if (!overlays.pairRange || pairRanges.length === 0) return []; - if (hoveredPairMmsiSetRef.size < 2) return []; + if (pairRanges.length === 0) return []; + if (effectiveHoveredPairMmsiSet.size < 2) return []; return pairRanges.filter((range) => - hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), + effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi), ); - }, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); + }, [pairRanges, effectiveHoveredPairMmsiSet]); const fcLinesInteractive = useMemo(() => { - if (!overlays.fcLines || fcDashed.length === 0) return []; + if (fcDashed.length === 0) return []; if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); + }, [fcDashed, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { - if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; + if ((fleetCircles?.length ?? 0) === 0) return []; if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); - }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]); // ── Hook orchestration ─────────────────────────────────────────────── const { ensureMercatorOverlay, pulseMapSync } = useMapInit( @@ -479,9 +559,9 @@ export function Map3D({ useGlobeShips( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { - projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet, shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, - onSelectMmsi, onToggleHighlightMmsi, targets, overlays, + onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, onGlobeShipsReady, }, @@ -508,7 +588,7 @@ export function Map3D({ useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { - projection, settings, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, @@ -535,9 +615,9 @@ export function Map3D({ }, ); - useVesselTrackLayer( - mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers, - { activeTrack, projection, mapSyncEpoch }, + useTrackReplayLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState }, ); // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 @@ -560,7 +640,7 @@ export function Map3D({ // Globe: MapLibre 네이티브 레이어에서 쿼리 const point: [number, number] = [e.offsetX, e.offsetY]; const shipLayerIds = [ - 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', ].filter((id) => map.getLayer(id)); let features: maplibregl.MapGeoJSONFeature[] = []; @@ -596,6 +676,14 @@ export function Map3D({ { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, ); + // Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출 + const mapReadyFiredRef = useRef(false); + useEffect(() => { + if (mapReadyFiredRef.current || !onMapReady || !mapRef.current) return; + mapReadyFiredRef.current = true; + onMapReady(mapRef.current); + }, [mapSyncEpoch, onMapReady]); + return ( <>
diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 92db352..3da1d96 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -104,7 +104,7 @@ 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, + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 0, ]; // ── Highlighted variants ── @@ -131,7 +131,7 @@ 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, + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 120, ]; // ── MapLibre overlay colors ── @@ -151,8 +151,8 @@ 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_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.12); +export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.42); 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); diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index fcabe3b..1eefe43 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -66,6 +66,7 @@ export function useDeckLayers( opts: { projection: MapProjectionId; settings: Map3DSettings; + trackReplayDeckLayers: unknown[]; shipLayerData: AisTarget[]; shipOverlayLayerData: AisTarget[]; shipData: AisTarget[]; @@ -103,7 +104,7 @@ export function useDeckLayers( }, ) { const { - projection, settings, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, @@ -465,16 +466,16 @@ export function useDeckLayers( } } - if (overlays.pairRange && pairRangesInteractive.length > 0) { + if (pairRangesInteractive.length > 0) { layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); } - if (overlays.pairLines && pairLinksInteractive.length > 0) { + if (pairLinksInteractive.length > 0) { layers.push(new LineLayer({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); } - if (overlays.fcLines && fcLinesInteractive.length > 0) { + if (fcLinesInteractive.length > 0) { layers.push(new LineLayer({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); } - if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + if (fleetCirclesInteractive.length > 0) { layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); } @@ -488,7 +489,9 @@ export function useDeckLayers( 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); + const normalizedBaseLayers = sanitizeDeckLayerList(layers); + const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers); + const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]); const deckProps = { layers: normalizedLayers, getTooltip: (info: PickingInfo) => { @@ -553,12 +556,7 @@ export function useDeckLayers( try { deckTarget.setProps(deckProps as never); } catch (e) { - console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e); - try { - deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); - } catch { - // Ignore secondary failure. - } + console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e); } }, [ ensureMercatorOverlay, @@ -583,6 +581,7 @@ export function useDeckLayers( overlays.fleetCircles, settings.showDensity, settings.showShips, + trackReplayDeckLayers, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts index 90e571c..c31a3ab 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -184,7 +184,7 @@ export function useGlobeInteraction( let candidateLayerIds: string[] = []; try { candidateLayerIds = [ - 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', 'pair-lines-ml', 'fc-lines-ml', 'fleet-circles-ml', 'pair-range-ml', @@ -211,7 +211,7 @@ export function useGlobeInteraction( } const priority = [ - 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', 'fleet-circles-ml', 'zones-fill', 'zones-line', 'zones-label', @@ -229,7 +229,11 @@ export function useGlobeInteraction( const layerId = first.layer?.id; const props = first.properties || {}; - const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; + const isShipLayer = + layerId === 'ships-globe' || + layerId === 'ships-globe-lite' || + layerId === 'ships-globe-halo' || + layerId === 'ships-globe-outline'; const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; const isFcLayer = layerId === 'fc-lines-ml'; const isFleetLayer = layerId === 'fleet-circles-ml'; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index db3768b..b2261d6 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -11,6 +11,7 @@ import { 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_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL, } from '../constants'; import { makeUniqueSorted } from '../lib/setUtils'; @@ -66,7 +67,8 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { remove(); return; } @@ -140,7 +142,7 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); // FC lines useEffect(() => { @@ -157,7 +159,9 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.fcLines) { + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + const fcHoverActive = fleetAwarePairMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { remove(); return; } @@ -235,7 +239,15 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [ + projection, + overlays.fcLines, + fcLinks, + hoveredPairMmsiList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Fleet circles useEffect(() => { @@ -243,26 +255,35 @@ export function useGlobeOverlays( if (!map) return; const srcId = 'fleet-circles-ml-src'; + const fillSrcId = 'fleet-circles-ml-fill-src'; const layerId = 'fleet-circles-ml'; + const fillLayerId = 'fleet-circles-ml-fill'; // fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인 // 라인만으로 fleet circle 시각화 충분 const remove = () => { guardedSetVisibility(map, layerId, 'none'); + guardedSetVisibility(map, fillLayerId, 'none'); }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { remove(); return; } + const circles = fleetCircles || []; + const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => + hoveredFleetOwnerKeyList.includes(ownerKey) || + (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); + const fcLine: GeoJSON.FeatureCollection = { type: 'FeatureCollection', - features: (fleetCircles || []).map((c) => { + features: circles.map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: 'Feature', @@ -280,6 +301,23 @@ export function useGlobeOverlays( }), }; + const fcFill: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: circles + .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) + .map((c) => ({ + type: 'Feature', + id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), + geometry: { + type: 'Polygon', + coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], + }, + properties: { + ownerKey: c.ownerKey, + }, + })), + }; + try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fcLine); @@ -289,6 +327,14 @@ export function useGlobeOverlays( return; } + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles fill source setup failed:', e); + } + if (!map.getLayer(layerId)) { try { map.addLayer( @@ -312,6 +358,27 @@ export function useGlobeOverlays( guardedSetVisibility(map, layerId, 'visible'); } + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: fillSrcId, + layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, + paint: { + 'fill-color': FLEET_FILL_ML_HL, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles fill layer add failed:', e); + } + } else { + guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); + } + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -321,7 +388,15 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + hoveredFleetOwnerKeyList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Pair range useEffect(() => { @@ -338,7 +413,8 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.pairRange) { + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { remove(); return; } @@ -427,7 +503,7 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); // Paint state updates for hover highlights // eslint-disable-next-line react-hooks/preserve-manual-memoization diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 5d55bdb..8cbc4ee 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -270,13 +270,14 @@ export function useGlobeShips( const srcId = 'ships-globe-src'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; + const symbolLiteId = 'ships-globe-lite'; const symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { - for (const id of [labelId, symbolId, outlineId, haloId]) { + for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { guardedSetVisibility(map, id, 'none'); } }; @@ -300,10 +301,12 @@ export function useGlobeShips( // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; - if (map.getLayer(symbolId)) { - const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility; + if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { + const changed = + map.getLayoutProperty(symbolId, 'visibility') !== visibility || + map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; if (changed) { - for (const id of [haloId, outlineId, symbolId]) { + for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { guardedSetVisibility(map, id, visibility); } if (projection === 'globe') kickRepaint(map); @@ -342,6 +345,18 @@ export function useGlobeShips( } const before = undefined; + const priorityFilter = [ + 'any', + ['==', ['to-number', ['get', 'permitted'], 0], 1], + ['==', ['to-number', ['get', 'selected'], 0], 1], + ['==', ['to-number', ['get', 'highlighted'], 0], 1], + ] as unknown as unknown[]; + const nonPriorityFilter = [ + 'all', + ['==', ['to-number', ['get', 'permitted'], 0], 0], + ['==', ['to-number', ['get', 'selected'], 0], 0], + ['==', ['to-number', ['get', 'highlighted'], 0], 0], + ] as unknown as unknown[]; if (!map.getLayer(haloId)) { try { @@ -428,6 +443,76 @@ export function useGlobeShips( } // outline: data-driven expressions are static — visibility handled by fast toggle + if (!map.getLayer(symbolLiteId)) { + try { + map.addLayer( + { + id: symbolLiteId, + type: 'symbol', + source: srcId, + minzoom: 6.5, + filter: nonPriorityFilter as never, + layout: { + visibility, + 'symbol-sort-key': 40 as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], + 8, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], + 10, + ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], + 14, + ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], + 18, + ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + 0.16, + 8, + 0.34, + 11, + 0.54, + 14, + 0.68, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship lite symbol layer add failed:', e); + } + } + // lite symbol: lower LOD for non-priority vessels in low zoom + if (!map.getLayer(symbolId)) { try { map.addLayer( @@ -435,6 +520,7 @@ export function useGlobeShips( id: symbolId, type: 'symbol', source: srcId, + filter: priorityFilter as never, layout: { visibility, 'symbol-sort-key': [ @@ -475,10 +561,10 @@ export function useGlobeShips( 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': [ 'case', - ['==', ['get', 'permitted'], 1], 1, - ['==', ['get', 'selected'], 1], 0.86, - ['==', ['get', 'highlighted'], 1], 0.82, - 0.66, + ['==', ['get', 'selected'], 1], 1, + ['==', ['get', 'highlighted'], 1], 0.95, + ['==', ['get', 'permitted'], 1], 0.93, + 0.9, ] as never, }, } as unknown as LayerSpecification, @@ -820,13 +906,14 @@ export function useGlobeShips( if (projection !== 'globe' || !settings.showShips) return; const symbolId = 'ships-globe'; + const symbolLiteId = 'ships-globe-lite'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; const clickedRadiusDeg2 = Math.pow(0.08, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { - const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id)); let feats: unknown[] = []; if (layerIds.length > 0) { try { diff --git a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts index d82cd42..c6096b9 100644 --- a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts @@ -84,16 +84,27 @@ export function useNativeMapLayers( useEffect(() => { const map = mapRef.current; if (!map) return; + let cancelled = false; + let retryRaf: number | null = null; const ensure = () => { + if (cancelled) return; const cfg = configRef.current; - if (projectionBusyRef.current) return; // 1. Visibility 토글 for (const spec of cfg.layers) { setLayerVisibility(map, spec.id, cfg.visible); } + // projection transition 중에는 가시성 토글만 먼저 반영하고, + // source/layer 업데이트는 transition 종료 후 재시도한다. + if (projectionBusyRef.current) { + if (cfg.visible) { + retryRaf = requestAnimationFrame(ensure); + } + return; + } + // 2. 데이터가 있는 source가 하나도 없으면 종료 const hasData = cfg.sources.some((s) => s.data != null); if (!hasData) return; @@ -150,6 +161,10 @@ export function useNativeMapLayers( const stop = onMapStyleReady(map, ensure); return () => { + cancelled = true; + if (retryRaf != null) { + cancelAnimationFrame(retryRaf); + } stop(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 4b3e3cc..c38cc38 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -99,6 +99,10 @@ export function useProjectionToggle( 'vessel-track-arrow', 'vessel-track-pts', 'vessel-track-pts-highlight', + 'track-replay-globe-path', + 'track-replay-globe-points', + 'track-replay-globe-virtual-ship', + 'track-replay-globe-virtual-label', 'zones-fill', 'zones-line', 'zones-label', @@ -108,6 +112,7 @@ export function useProjectionToggle( 'predict-vectors-hl', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe-lite', 'ships-globe', 'ships-globe-label', 'ships-globe-hover-halo', @@ -116,6 +121,7 @@ export function useProjectionToggle( 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', + 'fleet-circles-ml-fill', 'fleet-circles-ml', ]; diff --git a/apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts b/apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts new file mode 100644 index 0000000..233f472 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts @@ -0,0 +1,242 @@ +import { useEffect, useMemo, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { ActiveTrack } from '../../../entities/vesselTrack/model/types'; +import { convertLegacyTrackPointsToProcessedTrack } from '../../../features/trackReplay/lib/adapters'; +import { useTrackQueryStore } from '../../../features/trackReplay/stores/trackQueryStore'; +import type { TrackReplayDeckRenderState } from '../../../features/trackReplay/hooks/useTrackReplayDeckLayers'; +import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers'; +import type { MapProjectionId } from '../types'; +import { kickRepaint } from '../lib/mapCore'; + +const GLOBE_LINE_SRC = 'track-replay-globe-line-src'; +const GLOBE_POINT_SRC = 'track-replay-globe-point-src'; +const GLOBE_VIRTUAL_SRC = 'track-replay-globe-virtual-src'; +const GLOBE_TRACK_LAYER_IDS = { + PATH: 'track-replay-globe-path', + POINTS: 'track-replay-globe-points', + VIRTUAL_SHIP: 'track-replay-globe-virtual-ship', + VIRTUAL_LABEL: 'track-replay-globe-virtual-label', +} as const; + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizeCoordinate(value: unknown): [number, number] | null { + if (!Array.isArray(value) || value.length !== 2) return null; + const lon = toFiniteNumber(value[0]); + const lat = toFiniteNumber(value[1]); + if (lon == null || lat == null) return null; + return [lon, lat]; +} + +export function useTrackReplayLayer( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + projection: MapProjectionId; + mapSyncEpoch: number; + activeTrack?: ActiveTrack | null; + renderState: TrackReplayDeckRenderState; + }, +) { + const { projection, mapSyncEpoch, activeTrack = null, renderState } = opts; + const { enabledTracks, currentPositions, showPoints, showVirtualShip, showLabels, renderEpoch } = renderState; + + const setTracks = useTrackQueryStore((state) => state.setTracks); + + // Backward compatibility path: if legacy activeTrack is provided, load it into the new store. + useEffect(() => { + if (!activeTrack) return; + if (!activeTrack.points || activeTrack.points.length === 0) return; + + const converted = convertLegacyTrackPointsToProcessedTrack(activeTrack.mmsi, activeTrack.points); + if (!converted) return; + + setTracks([converted]); + }, [activeTrack, setTracks]); + + const lineGeoJson = useMemo>(() => { + const features: GeoJSON.Feature[] = []; + for (const track of enabledTracks) { + const coordinates: [number, number][] = []; + for (const coord of track.geometry) { + const normalized = normalizeCoordinate(coord); + if (normalized) coordinates.push(normalized); + } + if (coordinates.length < 2) continue; + + features.push({ + type: 'Feature', + properties: { + vesselId: track.vesselId, + shipName: track.shipName, + }, + geometry: { + type: 'LineString', + coordinates, + }, + }); + } + + return { + type: 'FeatureCollection', + features, + }; + }, [enabledTracks]); + + const pointGeoJson = useMemo>(() => { + const features: GeoJSON.Feature[] = []; + for (const track of enabledTracks) { + track.geometry.forEach((coord, index) => { + const normalized = normalizeCoordinate(coord); + if (!normalized) return; + features.push({ + type: 'Feature', + properties: { + vesselId: track.vesselId, + shipName: track.shipName, + index, + }, + geometry: { + type: 'Point', + coordinates: normalized, + }, + }); + }); + } + + return { + type: 'FeatureCollection', + features, + }; + }, [enabledTracks]); + + const virtualGeoJson = useMemo>(() => { + const features: GeoJSON.Feature[] = []; + for (const position of currentPositions) { + const normalized = normalizeCoordinate(position.position); + if (!normalized) continue; + features.push({ + type: 'Feature', + properties: { + vesselId: position.vesselId, + shipName: position.shipName, + }, + geometry: { + type: 'Point', + coordinates: normalized, + }, + }); + } + + return { + type: 'FeatureCollection', + features, + }; + }, [currentPositions]); + + const globeSources = useMemo( + () => [ + { id: GLOBE_LINE_SRC, data: lineGeoJson }, + { id: GLOBE_POINT_SRC, data: pointGeoJson }, + { id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson }, + ], + [lineGeoJson, pointGeoJson, virtualGeoJson], + ); + + const globeLayers = useMemo( + () => [ + { + id: GLOBE_TRACK_LAYER_IDS.PATH, + type: 'line', + sourceId: GLOBE_LINE_SRC, + paint: { + 'line-color': '#00d1ff', + 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4], + 'line-opacity': 0.8, + }, + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + }, + { + id: GLOBE_TRACK_LAYER_IDS.POINTS, + type: 'circle', + sourceId: GLOBE_POINT_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4], + 'circle-color': '#00d1ff', + 'circle-opacity': showPoints ? 0.8 : 0, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(2,6,23,0.8)', + }, + }, + { + id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_SHIP, + type: 'circle', + sourceId: GLOBE_VIRTUAL_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 2.5, 8, 4, 12, 6], + 'circle-color': '#f59e0b', + 'circle-opacity': showVirtualShip ? 0.9 : 0, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(255,255,255,0.8)', + }, + }, + { + id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_LABEL, + type: 'symbol', + sourceId: GLOBE_VIRTUAL_SRC, + paint: { + 'text-color': 'rgba(226,232,240,0.95)', + 'text-opacity': showLabels ? 1 : 0, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1, + }, + layout: { + 'text-field': ['get', 'shipName'], + 'text-size': 11, + 'text-anchor': 'left', + 'text-offset': [0.8, 0], + }, + }, + ], + [showLabels, showPoints, showVirtualShip], + ); + + const isGlobeVisible = projection === 'globe' && enabledTracks.length > 0; + + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: globeSources, + layers: globeLayers, + visible: isGlobeVisible, + beforeLayer: ['zones-fill', 'zones-line'], + }, + [projection, mapSyncEpoch, renderEpoch, lineGeoJson, pointGeoJson, virtualGeoJson, showPoints, showVirtualShip, showLabels], + ); + + useEffect(() => { + if (projection !== 'globe') return; + if (!isGlobeVisible) return; + const map = mapRef.current; + if (!map || projectionBusyRef.current) return; + + kickRepaint(map); + const id = requestAnimationFrame(() => { + kickRepaint(map); + }); + return () => cancelAnimationFrame(id); + }, [projection, isGlobeVisible, renderEpoch, mapRef, projectionBusyRef]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts index e46bc6f..ce734eb 100644 --- a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts @@ -107,6 +107,7 @@ const GLOBE_LAYERS: NativeLayerSpec[] = [ const ANIM_CYCLE_SEC = 20; /* ── Hook ──────────────────────────────────────────────────────────── */ +/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */ export function useVesselTrackLayer( mapRef: MutableRefObject, overlayRef: MutableRefObject, diff --git a/apps/web/src/widgets/map3d/lib/mapCore.ts b/apps/web/src/widgets/map3d/lib/mapCore.ts index 9dca30e..1a35be3 100644 --- a/apps/web/src/widgets/map3d/lib/mapCore.ts +++ b/apps/web/src/widgets/map3d/lib/mapCore.ts @@ -103,6 +103,19 @@ export function sanitizeDeckLayerList(value: unknown): unknown[] { let dropped = 0; for (const layer of value) { + // Deck layer instances expose `id`, `props`, and `clone`. + // Filter out MapLibre native layer specs that only share an `id`. + const isDeckLayerLike = + !!layer && + typeof layer === 'object' && + typeof (layer as { id?: unknown }).id === 'string' && + typeof (layer as { clone?: unknown }).clone === 'function' && + typeof (layer as { props?: unknown }).props === 'object'; + if (!isDeckLayerLike) { + dropped += 1; + continue; + } + const layerId = getLayerId(layer); if (!layerId) { dropped += 1; diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts index 7e4fc59..80b79e6 100644 --- a/apps/web/src/widgets/map3d/lib/shipUtils.ts +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -63,10 +63,11 @@ export function getGlobeBaseShipColor({ 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)'; + // Keep alpha control in icon-opacity only to avoid double-multiplying transparency. + if (!isFiniteNumber(sog)) return '#64748b'; + if (sog >= 10) return '#94a3b8'; + if (sog >= 1) return '#64748b'; + return '#475569'; } export function getShipColor( diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 55bfabf..6a1d61d 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -60,6 +60,7 @@ export interface Map3DProps { onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; mapStyleSettings?: MapStyleSettings; + onMapReady?: (map: import('maplibre-gl').Map) => void; initialView?: MapViewState | null; onViewStateChange?: (view: MapViewState) => void; onGlobeShipsReady?: (ready: boolean) => void; diff --git a/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx new file mode 100644 index 0000000..f5c8ca4 --- /dev/null +++ b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore'; +import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; + +function formatDateTime(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '--'; + const date = new Date(ms); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad( + date.getMinutes(), + )}:${pad(date.getSeconds())}`; +} + +export function GlobalTrackReplayPanel() { + const PANEL_WIDTH = 420; + const PANEL_MARGIN = 12; + const PANEL_DEFAULT_TOP = 16; + const PANEL_RIGHT_RESERVED = 520; + + const panelRef = useRef(null); + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>( + null, + ); + const [isDragging, setIsDragging] = useState(false); + + const clampPosition = useCallback( + (x: number, y: number) => { + if (typeof window === 'undefined') return { x, y }; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const panelHeight = panelRef.current?.offsetHeight ?? 360; + return { + x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)), + y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)), + }; + }, + [PANEL_MARGIN, PANEL_WIDTH], + ); + + const [position, setPosition] = useState(() => { + if (typeof window === 'undefined') { + return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP }; + } + return { + x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED), + y: PANEL_DEFAULT_TOP, + }; + }); + + const tracks = useTrackQueryStore((state) => state.tracks); + const isLoading = useTrackQueryStore((state) => state.isLoading); + const error = useTrackQueryStore((state) => state.error); + const showPoints = useTrackQueryStore((state) => state.showPoints); + const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip); + const showLabels = useTrackQueryStore((state) => state.showLabels); + const showTrail = useTrackQueryStore((state) => state.showTrail); + const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips); + const setShowPoints = useTrackQueryStore((state) => state.setShowPoints); + const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip); + const setShowLabels = useTrackQueryStore((state) => state.setShowLabels); + const setShowTrail = useTrackQueryStore((state) => state.setShowTrail); + const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips); + const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery); + + const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); + const currentTime = useTrackPlaybackStore((state) => state.currentTime); + const startTime = useTrackPlaybackStore((state) => state.startTime); + const endTime = useTrackPlaybackStore((state) => state.endTime); + const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed); + const loop = useTrackPlaybackStore((state) => state.loop); + const play = useTrackPlaybackStore((state) => state.play); + const pause = useTrackPlaybackStore((state) => state.pause); + const stop = useTrackPlaybackStore((state) => state.stop); + const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime); + const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed); + const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop); + + const progress = useMemo(() => { + if (endTime <= startTime) return 0; + return ((currentTime - startTime) / (endTime - startTime)) * 100; + }, [startTime, endTime, currentTime]); + const isVisible = isLoading || tracks.length > 0 || !!error; + + useEffect(() => { + if (!isVisible) return; + if (typeof window === 'undefined') return; + const onResize = () => { + setPosition((prev) => clampPosition(prev.x, prev.y)); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [clampPosition, isVisible]); + + useEffect(() => { + if (!isVisible) return; + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + setPosition(() => { + const nextX = drag.originX + (event.clientX - drag.startX); + const nextY = drag.originY + (event.clientY - drag.startY); + return clampPosition(nextX, nextY); + }); + }; + + const stopDrag = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + setIsDragging(false); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', stopDrag); + window.addEventListener('pointercancel', stopDrag); + + return () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', stopDrag); + window.removeEventListener('pointercancel', stopDrag); + }; + }, [clampPosition, isVisible]); + + const handleHeaderPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: position.x, + originY: position.y, + }; + setIsDragging(true); + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // ignore + } + }, + [position.x, position.y], + ); + + if (!isVisible) return null; + + return ( +
+
+ Track Replay + +
+ + {error ? ( +
{error}
+ ) : null} + + {isLoading ?
항적 조회 중...
: null} + +
+ 선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)} +
+ +
+ + + +
+ +
+ setCurrentTime(Number(event.target.value))} + style={{ width: '100%' }} + disabled={tracks.length === 0 || endTime <= startTime} + /> +
+ {formatDateTime(currentTime)} + {Math.max(0, Math.min(100, progress)).toFixed(1)}% +
+
+ +
+ + + + + + +
+
+ ); +} diff --git a/apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx b/apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx new file mode 100644 index 0000000..fcc45da --- /dev/null +++ b/apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx @@ -0,0 +1,316 @@ +import { useMemo, useRef, useState } from 'react'; +import { + WEATHER_LAYERS, + LEGEND_META, + SPEED_OPTIONS, + type WeatherLayerId, + type WeatherOverlayState, + type WeatherOverlayActions, +} from '../../features/weatherOverlay/useWeatherOverlay'; + +type Props = WeatherOverlayState & WeatherOverlayActions; + +/** 절대 시간 표기 (MM. DD. HH:mm) */ +function fmtBase(d: Date | null): string { + if (!d) return '-'; + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${mm}. ${dd}. ${hh}:${mi}`; +} + +/** epoch 초 → Date */ +function secToDate(sec: number): Date { + return new Date(sec * 1000); +} + +/** 기준시간 대비 오프셋 표기 (+HH:MM) */ +function fmtOffset(base: Date | null, target: Date | null): string { + if (!base || !target) return '-'; + const diffMs = target.getTime() - base.getTime(); + const totalMin = Math.round(diffMs / 60_000); + const h = Math.floor(Math.abs(totalMin) / 60); + const m = Math.abs(totalMin) % 60; + const sign = totalMin >= 0 ? '+' : '-'; + return `${sign}${h}:${String(m).padStart(2, '0')}`; +} + +/** ColorRamp → CSS linear-gradient */ +function rampToGradient(layerId: WeatherLayerId): string { + const { colorRamp } = LEGEND_META[layerId]; + const stops = colorRamp.getRawColorStops(); + if (stops.length === 0) return 'transparent'; + const { min, max } = colorRamp.getBounds(); + const range = max - min || 1; + const css = stops.map((s) => { + const [r, g, b, a] = s.color; + const pct = ((s.value - min) / range) * 100; + return `rgba(${r},${g},${b},${(a ?? 255) / 255}) ${pct.toFixed(1)}%`; + }); + return `linear-gradient(to right, ${css.join(', ')})`; +} + +/** 범례 눈금 생성 */ +function getLegendTicks(layerId: WeatherLayerId): string[] { + const { colorRamp, unit } = LEGEND_META[layerId]; + const { min, max } = colorRamp.getBounds(); + const count = 5; + const step = (max - min) / count; + const ticks: string[] = []; + for (let i = 0; i <= count; i++) { + const v = min + step * i; + ticks.push(Number.isInteger(v) ? String(v) : v.toFixed(1)); + } + ticks[ticks.length - 1] += unit; + return ticks; +} + +/** steps 배열에서 value 에 가장 가까운 step epoch 초 반환 */ +function snapToStep(steps: number[], value: number): number { + if (steps.length === 0) return value; + let best = steps[0]; + let bestDist = Math.abs(value - best); + for (let i = 1; i < steps.length; i++) { + const dist = Math.abs(value - steps[i]); + if (dist < bestDist) { + best = steps[i]; + bestDist = dist; + } + } + return best; +} + +export function WeatherOverlayPanel(props: Props) { + const { + enabled, + activeLayerId, + opacity, + isPlaying, + animationSpeed, + currentTime, + startTime, + endTime, + steps, + isReady, + toggleLayer, + setOpacity, + play, + pause, + setSpeed, + seekTo, + } = props; + + const [open, setOpen] = useState(false); + const activeCount = Object.values(enabled).filter(Boolean).length; + /** 드래그 중 현재 step index */ + const [draggingIdx, setDraggingIdx] = useState(null); + /** 마지막으로 seek 요청한 step index (중복 방지) */ + const lastSeekedRef = useRef(null); + + const legendGradient = useMemo( + () => (activeLayerId ? rampToGradient(activeLayerId) : null), + [activeLayerId], + ); + const legendTicks = useMemo( + () => (activeLayerId ? getLegendTicks(activeLayerId) : []), + [activeLayerId], + ); + + // 현재 시간을 step index로 변환 (재생 중 폴링 값 반영) + const currentStepIdx = + draggingIdx != null + ? draggingIdx + : steps.length > 0 && currentTime + ? (() => { + const curSec = currentTime.getTime() / 1000; + const snapped = snapToStep(steps, curSec); + return steps.indexOf(snapped); + })() + : 0; + + /** 드래그 중: step 경계를 넘을 때마다 실시간 seek */ + const handleSliderInput = (e: React.ChangeEvent) => { + const idx = Number(e.target.value); + setDraggingIdx(idx); + // step이 바뀌었을 때만 seek (동일 step 반복 방지) + if (lastSeekedRef.current !== idx && steps[idx] != null) { + lastSeekedRef.current = idx; + seekTo(steps[idx]); + } + }; + + /** 드래그 끝: 정리 */ + const handleSliderCommit = () => { + lastSeekedRef.current = null; + setDraggingIdx(null); + }; + + const legendInfo = activeLayerId ? LEGEND_META[activeLayerId] : null; + const showPanel = open; + const showLegend = !!activeLayerId && !!legendInfo && !!legendGradient; + + return ( + <> + + + {(showPanel || showLegend) && ( +
+ {showPanel && ( +
+
+ 기상 타일 오버레이 + {!isReady && activeCount > 0 && 로딩...} +
+ +
+ {WEATHER_LAYERS.map((meta) => ( + + ))} +
+ +
+
+ 투명도 {Math.round(opacity * 100)}% +
+ setOpacity(Number(e.target.value) / 100)} + /> +
+ + {activeCount > 0 && steps.length > 0 && ( +
+
+ 기준 {fmtBase(startTime)} + {' · '} + {fmtOffset(startTime, currentTime)} +
+ + {/* step 기반 슬라이더 */} +
+ + {/* 눈금 표시 */} +
+ {steps.map((sec, i) => { + const pct = steps.length > 1 ? (i / (steps.length - 1)) * 100 : 0; + const d = secToDate(sec); + const isDay = d.getHours() === 0 && d.getMinutes() === 0; + return ( + + ); + })} +
+
+ +
+ {fmtBase(startTime)} + + {draggingIdx != null && steps[draggingIdx] + ? fmtBase(secToDate(steps[draggingIdx])) + : fmtBase(currentTime)} + + {fmtBase(endTime)} +
+ +
+ +
+ {SPEED_OPTIONS.map((opt) => ( + + ))} +
+
+
+ )} + +
+ MapTiler Weather SDK +
+
+ )} + + {showLegend && ( +
+
+ {legendInfo!.label} ({legendInfo!.unit}) +
+
+
+ {legendTicks.map((t, i) => ( + {t} + ))} +
+
+ )} +
+ )} + + ); +} diff --git a/apps/web/src/widgets/weatherPanel/WeatherPanel.tsx b/apps/web/src/widgets/weatherPanel/WeatherPanel.tsx new file mode 100644 index 0000000..84b11f5 --- /dev/null +++ b/apps/web/src/widgets/weatherPanel/WeatherPanel.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import type { WeatherSnapshot } from '../../entities/weather/model/types'; +import { + getWindDirectionLabel, + getWindArrow, + getWaveSeverity, + getWeatherLabel, +} from '../../entities/weather/lib/weatherUtils'; + +interface WeatherPanelProps { + snapshot: WeatherSnapshot | null; + isLoading: boolean; + error: string | null; + onRefresh: () => void; +} + +function fmtTime(ts: number): string { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function fmtVal(v: number | null, unit: string, decimals = 1): string { + if (v == null) return '-'; + return `${v.toFixed(decimals)}${unit}`; +} + +export function WeatherPanel({ snapshot, isLoading, error, onRefresh }: WeatherPanelProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + + {open && ( +
+
+ 해양 기상 현황 + {isLoading && 조회중...} +
+ + {error &&
{error}
} + + {snapshot && snapshot.points.map((pt) => { + const severity = getWaveSeverity(pt.waveHeight); + const isWarn = severity === 'rough' || severity === 'severe' + || (pt.windSpeed != null && pt.windSpeed >= 10); + + return ( +
+
{pt.label}
+
+ + ~ + {fmtVal(pt.waveHeight, 'm')} + + + {getWindArrow(pt.windDirection)} + + {fmtVal(pt.windSpeed, 'm/s')} {getWindDirectionLabel(pt.windDirection)} + + +
+
+ + 수온 + {fmtVal(pt.seaSurfaceTemp, '°C')} + + + 너울 + {fmtVal(pt.swellHeight, 'm')} + + {pt.weatherCode != null && ( + + {getWeatherLabel(pt.weatherCode)} + + )} +
+
+ ); + })} + + {!snapshot && !isLoading && !error && ( +
데이터 없음
+ )} + +
+ {snapshot && ( + 갱신 {fmtTime(snapshot.fetchedAt)} + )} + +
+
+ )} + + ); +} diff --git a/package-lock.json b/package-lock.json index ff6a498..84a7523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,14 +31,18 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/extensions": "^9.2.7", "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", + "@maptiler/weather": "^3.1.1", "@react-oauth/google": "^0.13.4", + "@stomp/stompjs": "^7.2.1", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router": "^7.13.0" + "react-router": "^7.13.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1896,6 +1900,114 @@ "supercluster": "^8.0.1" } }, + "node_modules/@maptiler/client": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@maptiler/client/-/client-2.4.0.tgz", + "integrity": "sha512-94g2zmGBfgSIY0iJhrANDUfANTH7G/NNZ8mScLLEE+LZfAtaehQ4cvd/EwtuiW3x7ANy92Vzh9bVaKr6xQTSmA==", + "license": "BSD-3-Clause", + "dependencies": { + "quick-lru": "^7.0.0" + } + }, + "node_modules/@maptiler/sdk": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@maptiler/sdk/-/sdk-3.5.0.tgz", + "integrity": "sha512-Z/OV9+iEGBc9eac2HVsbbV36645xaROIjHaOj7D/QGM3p1Z52A+//Uls55WTNF8/Y2iBBIQxWAb7zeKqtXAArg==", + "license": "BSD-3-Clause", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "~23.3.0", + "@maptiler/client": "~2.4.0", + "events": "^3.3.0", + "gl-matrix": "^3.4.3", + "js-base64": "^3.7.7", + "maplibre-gl": "~5.6.0", + "uuid": "^11.0.5" + } + }, + "node_modules/@maptiler/sdk/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "23.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.3.0.tgz", + "integrity": "sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maptiler/sdk/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/@maptiler/sdk/node_modules/maplibre-gl": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz", + "integrity": "sha512-SEqYThhUCFf6Lm0TckpgpKnto5u4JsdPYdFJb6g12VtuaFsm3nYdBO+fOmnUYddc8dXihgoGnuXvPPooUcRv5w==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^23.3.0", + "@maplibre/vt-pbf": "^4.0.3", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/@maptiler/weather": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@maptiler/weather/-/weather-3.1.1.tgz", + "integrity": "sha512-PB4JUfzb/nwiNzjr4/visFcxIVSw8b2nZNRrNBSay8rmrzBB2DZvx1Me3mB8nOPJtvFQ8wDOTlNnDV6yJvgY9A==", + "dependencies": { + "@maptiler/sdk": "~3.5.0", + "events": "^3.3.0", + "lru-cache": "^11.0.2", + "ol": "^10.3.1", + "three": "~0.135.0" + } + }, + "node_modules/@maptiler/weather/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@math.gl/core": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", @@ -1955,6 +2067,12 @@ "@math.gl/core": "4.1.0" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2349,6 +2467,12 @@ "win32" ] }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@turf/boolean-clockwise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", @@ -2478,6 +2602,15 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2506,11 +2639,17 @@ "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", "license": "MIT" }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2835,6 +2974,16 @@ "resolved": "apps/web", "link": true }, + "node_modules/@zarrita/storage": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz", + "integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "1.4.3" + } + }, "node_modules/a5-js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", @@ -3283,7 +3432,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d3-hexbin": { @@ -3607,6 +3756,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -3916,6 +4074,49 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/geotiff": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz", + "integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/geotiff/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/geotiff/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4149,6 +4350,12 @@ "dev": true, "license": "ISC" }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4275,6 +4482,12 @@ "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", "license": "MIT" }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4518,6 +4731,45 @@ "dev": true, "license": "MIT" }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, + "node_modules/numcodecs/node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/ol": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz", + "integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^3.0.2", + "pbf": "4.0.1", + "rbush": "^4.0.0", + "zarrita": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, + "node_modules/ol/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -4596,6 +4848,12 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4775,12 +5033,33 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4860,6 +5139,12 @@ "node": ">= 12.13.0" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5218,6 +5503,12 @@ "node": ">=20" } }, + "node_modules/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.135.0.tgz", + "integrity": "sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5341,6 +5632,18 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unzipit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", + "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "license": "MIT", + "dependencies": { + "uzip-module": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5388,6 +5691,25 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/uzip-module": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -5464,6 +5786,12 @@ } } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/wgsl_reflect": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", @@ -5538,6 +5866,12 @@ "node": ">=0.8" } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5558,6 +5892,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zarrita": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz", + "integrity": "sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.1.4", + "numcodecs": "^0.3.2" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -5588,6 +5932,41 @@ "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", "license": "MIT", "optional": true + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } }