From cb6493b8a170bcf4c1f7e956054dca258600c4d5 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 21:45:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(weather):=20=EA=B8=B0=EC=83=81=20?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20+?= =?UTF-8?q?=20=EC=88=98=EC=97=AD=EB=B3=84=20=EB=82=A0=EC=94=A8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapTiler Weather SDK 6종 기상 타일 오버레이: - 바람/기온/강수/기압/레이더/구름 라디오 토글 - 3시간 단위 step 스냅 타임라인 + 드래그 실시간 seek - 색상 범례, 배속 제어, 투명도 조절 - ServiceWorker 타일 캐시 (cache-first, 최대 2000장) - SDK 시간 단위(epoch 초) 정합성 보장 Open-Meteo 수역별 기상 패널: - 4개 수역 centroid 기반 해양/기상 데이터 5분 폴링 - 파고/풍속/수온/너울 카드 UI + 경고 하이라이트 Co-Authored-By: Claude Opus 4.6 --- apps/web/package.json | 1 + apps/web/public/sw-weather-cache.js | 62 ++ apps/web/src/app/styles.css | 543 ++++++++++++++++++ .../src/entities/weather/api/fetchWeather.ts | 107 ++++ .../src/entities/weather/lib/weatherUtils.ts | 78 +++ apps/web/src/entities/weather/model/types.ts | 40 ++ .../weatherOverlay/useWeatherOverlay.ts | 415 +++++++++++++ .../weatherOverlay/useWeatherPolling.ts | 97 ++++ .../web/src/pages/dashboard/DashboardPage.tsx | 20 +- apps/web/src/widgets/map3d/Map3D.tsx | 9 + .../web/src/widgets/map3d/hooks/useMapInit.ts | 1 + apps/web/src/widgets/map3d/types.ts | 1 + .../weatherOverlay/WeatherOverlayPanel.tsx | 316 ++++++++++ .../src/widgets/weatherPanel/WeatherPanel.tsx | 118 ++++ package-lock.json | 341 +++++++++++ 15 files changed, 2148 insertions(+), 1 deletion(-) create mode 100644 apps/web/public/sw-weather-cache.js create mode 100644 apps/web/src/entities/weather/api/fetchWeather.ts create mode 100644 apps/web/src/entities/weather/lib/weatherUtils.ts create mode 100644 apps/web/src/entities/weather/model/types.ts create mode 100644 apps/web/src/features/weatherOverlay/useWeatherOverlay.ts create mode 100644 apps/web/src/features/weatherOverlay/useWeatherPolling.ts create mode 100644 apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx create mode 100644 apps/web/src/widgets/weatherPanel/WeatherPanel.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 39bbdea..e98224e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@deck.gl/core": "^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", 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 a57ba8d..ecb74c2 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -1258,6 +1258,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/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 eabce78..a96a64f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../../shared/auth"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; @@ -26,6 +26,10 @@ import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; +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 { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; import type { MapStyleSettings } from "../../features/mapSettings/types"; @@ -80,6 +84,12 @@ export function DashboardPage() { const { data: legacyData, error: legacyError } = useLegacyVessels(); 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); @@ -732,8 +742,16 @@ export function DashboardPage() { onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} + onMapReady={handleMapReady} /> + + {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c3a534f..543c973 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -66,6 +66,7 @@ export function Map3D({ onHoverCable, onClickCable, mapStyleSettings, + onMapReady, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -528,5 +529,13 @@ 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/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..eaed3df 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -95,6 +95,7 @@ export function useMapInit( map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); mapRef.current = map; + setMapSyncEpoch((prev) => prev + 1); if (projectionRef.current === 'mercator') { const overlay = ensureMercatorOverlay(); diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 16d1d1f..a7be0ba 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -52,6 +52,7 @@ export interface Map3DProps { onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; mapStyleSettings?: MapStyleSettings; + onMapReady?: (map: import('maplibre-gl').Map) => void; } export type DashSeg = { 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 0aa3a3d..adc6ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@deck.gl/core": "^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", @@ -1511,6 +1512,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", @@ -1550,6 +1659,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", @@ -2002,6 +2117,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", @@ -2025,6 +2149,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "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", @@ -2354,6 +2484,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/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -3022,6 +3162,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", @@ -3207,6 +3356,12 @@ } } }, + "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/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3307,6 +3462,43 @@ "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/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", @@ -3467,6 +3659,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", @@ -3575,6 +3773,12 @@ "json-buffer": "3.0.1" } }, + "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", @@ -3776,6 +3980,39 @@ "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/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", @@ -3835,6 +4072,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3848,6 +4091,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", @@ -4021,12 +4270,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", @@ -4091,6 +4361,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", @@ -4382,6 +4658,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", @@ -4506,6 +4788,18 @@ "dev": true, "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", @@ -4547,6 +4841,25 @@ "punycode": "^2.1.0" } }, + "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", @@ -4623,6 +4936,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", @@ -4697,6 +5016,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", @@ -4717,6 +5042,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", @@ -4740,6 +5075,12 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "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" } } } From 6acf2045b2bc9832e764423396aaac02947013e8 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 22:12:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20vessel-track=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20(squash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 54 +- apps/api/src/index.ts | 46 + apps/web/package.json | 6 +- apps/web/src/app/styles.css | 1 + .../aisTarget/api/searchChnprmship.ts | 32 + .../entities/vesselTrack/api/fetchTrack.ts | 32 + .../vesselTrack/lib/buildTrackGeoJson.ts | 115 +++ .../src/entities/vesselTrack/model/types.ts | 39 + .../aisPolling/useAisTargetPolling.ts | 109 ++- .../liveRenderer/core/ShipBatchRenderer.ts | 240 +++++ .../liveRenderer/hooks/useLiveShipAdapter.ts | 11 + .../hooks/useLiveShipBatchRender.ts | 88 ++ .../src/features/liveRenderer/lib/adapters.ts | 63 ++ .../liveRenderer/model/liveShip.types.ts | 42 + .../hooks/useTrackReplayDeckLayers.ts | 125 +++ .../trackReplay/layers/replayLayers.ts | 60 ++ .../trackReplay/layers/trackLayers.ts | 200 ++++ .../src/features/trackReplay/lib/adapters.ts | 159 +++ .../features/trackReplay/lib/interpolate.ts | 91 ++ .../features/trackReplay/model/track.types.ts | 45 + .../services/replayStreamService.ts | 35 + .../trackReplay/services/trackQueryService.ts | 162 ++++ .../trackReplay/stores/trackPlaybackStore.ts | 162 ++++ .../trackReplay/stores/trackQueryStore.ts | 210 ++++ .../web/src/pages/dashboard/DashboardPage.tsx | 133 ++- apps/web/src/shared/hooks/index.ts | 1 + .../web/src/shared/hooks/usePersistedState.ts | 103 ++ apps/web/src/shared/lib/datetime.ts | 50 + apps/web/src/widgets/aisInfo/AisInfoPanel.tsx | 5 +- .../widgets/aisTargetList/AisTargetList.tsx | 10 +- apps/web/src/widgets/info/VesselInfoPanel.tsx | 3 +- apps/web/src/widgets/map3d/Map3D.tsx | 237 ++++- .../map3d/components/VesselContextMenu.tsx | 135 +++ apps/web/src/widgets/map3d/constants.ts | 8 +- .../widgets/map3d/hooks/useBaseMapToggle.ts | 2 +- .../src/widgets/map3d/hooks/useDeckLayers.ts | 48 +- .../map3d/hooks/useGlobeInteraction.ts | 18 +- .../widgets/map3d/hooks/useGlobeOverlays.ts | 180 ++-- .../src/widgets/map3d/hooks/useGlobeShips.ts | 473 ++++----- .../web/src/widgets/map3d/hooks/useMapInit.ts | 147 ++- .../map3d/hooks/useMapStyleSettings.ts | 17 +- .../widgets/map3d/hooks/useNativeMapLayers.ts | 17 +- .../map3d/hooks/useProjectionToggle.ts | 97 +- .../widgets/map3d/hooks/useSubcablesLayer.ts | 65 +- .../map3d/hooks/useTrackReplayLayer.ts | 242 +++++ .../map3d/hooks/useVesselTrackLayer.ts | 396 ++++++++ .../src/widgets/map3d/hooks/useZonesLayer.ts | 78 +- .../src/widgets/map3d/layers/bathymetry.ts | 202 ++-- apps/web/src/widgets/map3d/lib/geometry.ts | 19 +- .../web/src/widgets/map3d/lib/layerHelpers.ts | 35 +- apps/web/src/widgets/map3d/lib/mapCore.ts | 13 + .../src/widgets/map3d/lib/shipIconCache.ts | 30 + apps/web/src/widgets/map3d/lib/shipUtils.ts | 9 +- apps/web/src/widgets/map3d/lib/tooltips.ts | 54 +- apps/web/src/widgets/map3d/types.ts | 16 + .../subcableInfo/SubcableInfoPanel.tsx | 156 ++- .../trackReplay/GlobalTrackReplayPanel.tsx | 307 ++++++ package-lock.json | 904 +++++++++++++++++- 58 files changed, 5478 insertions(+), 859 deletions(-) create mode 100644 apps/web/src/entities/aisTarget/api/searchChnprmship.ts create mode 100644 apps/web/src/entities/vesselTrack/api/fetchTrack.ts create mode 100644 apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts create mode 100644 apps/web/src/entities/vesselTrack/model/types.ts create mode 100644 apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts create mode 100644 apps/web/src/features/liveRenderer/hooks/useLiveShipAdapter.ts create mode 100644 apps/web/src/features/liveRenderer/hooks/useLiveShipBatchRender.ts create mode 100644 apps/web/src/features/liveRenderer/lib/adapters.ts create mode 100644 apps/web/src/features/liveRenderer/model/liveShip.types.ts create mode 100644 apps/web/src/features/trackReplay/hooks/useTrackReplayDeckLayers.ts create mode 100644 apps/web/src/features/trackReplay/layers/replayLayers.ts create mode 100644 apps/web/src/features/trackReplay/layers/trackLayers.ts create mode 100644 apps/web/src/features/trackReplay/lib/adapters.ts create mode 100644 apps/web/src/features/trackReplay/lib/interpolate.ts create mode 100644 apps/web/src/features/trackReplay/model/track.types.ts create mode 100644 apps/web/src/features/trackReplay/services/replayStreamService.ts create mode 100644 apps/web/src/features/trackReplay/services/trackQueryService.ts create mode 100644 apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts create mode 100644 apps/web/src/features/trackReplay/stores/trackQueryStore.ts create mode 100644 apps/web/src/shared/hooks/index.ts create mode 100644 apps/web/src/shared/hooks/usePersistedState.ts create mode 100644 apps/web/src/shared/lib/datetime.ts create mode 100644 apps/web/src/widgets/map3d/components/VesselContextMenu.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts create mode 100644 apps/web/src/widgets/map3d/lib/shipIconCache.ts create mode 100644 apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 164c874..52e1587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,34 +2,22 @@ ## 프로젝트 개요 -- **타입**: React + TypeScript + Vite (모노레포) +- **타입**: React 19 + TypeScript 5.9 + Vite 7 (모노레포) - **Node.js**: `.node-version` 참조 (v24) - **패키지 매니저**: npm (workspaces) -- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API) +- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API 프록시) ## 빌드 및 실행 ```bash -# 의존성 설치 -npm install - -# 전체 개발 서버 -npm run dev - -# 개별 개발 서버 -npm run dev:web # 프론트엔드 (Vite) -npm run dev:api # 백엔드 (Fastify + tsx watch) - -# 빌드 -npm run build # 전체 빌드 (web + api) -npm run build:web # 프론트엔드만 -npm run build:api # 백엔드만 - -# 린트 -npm run lint # apps/web ESLint - -# 데이터 준비 -npm run prepare:data +npm install # 의존성 설치 +npm run dev # 전체 개발 서버 +npm run dev:web # 프론트엔드 (Vite) +npm run dev:api # 백엔드 (Fastify + tsx watch) +npm run build # 전체 빌드 (web + api) +npm run build:web # 프론트엔드만 +npm run lint # apps/web ESLint +npm run prepare:data # 정적 데이터 준비 ``` ## 프로젝트 구조 @@ -37,19 +25,18 @@ npm run prepare:data ``` gc-wing-dev/ ├── apps/ -│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl +│ ├── web/ # @wing/web - React 19 + Vite 7 │ │ └── src/ -│ │ ├── app/ # App.tsx, styles -│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel) -│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등) -│ │ ├── pages/ # 페이지 (DashboardPage) -│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map) -│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등) -│ └── api/ # Fastify 5 + TypeScript -│ └── src/ -│ └── index.ts +│ │ ├── app/ # App.tsx, styles.css +│ │ ├── entities/ # 도메인 모델 (aisTarget, vessel, zone, legacyVessel, subcable) +│ │ ├── features/ # 기능 모듈 (aisPolling, legacyDashboard, map3dSettings, mapSettings, mapToggles, typeFilter) +│ │ ├── pages/ # dashboard, login, denied, pending +│ │ ├── shared/ # auth (Google OAuth), lib (geo, color, map), hooks (usePersistedState) +│ │ └── widgets/ # map3d, vesselList, info, alarms, relations, aisInfo, aisTargetList, topbar, speed, legend, subcableInfo +│ └── api/ # @wing/api - Fastify 5 +│ └── src/index.ts # AIS 프록시 + zones 엔드포인트 ├── data/ # 정적 데이터 -├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy) +├── scripts/ # prepare-zones.mjs, prepare-legacy.mjs └── legacy/ # 레거시 데이터 ``` @@ -59,6 +46,7 @@ gc-wing-dev/ |------|------| | 프론트엔드 | React 19, Vite 7, TypeScript 5.9 | | 지도 | MapLibre GL JS 5, Deck.gl 9 | +| 인증 | Google OAuth (AuthProvider + ProtectedRoute) | | 백엔드 | Fastify 5, TypeScript | | 린트 | ESLint 9, Prettier | diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bb571f7..f141ab8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) { return { lonMin, latMin, lonMax, latMax }; } +app.get<{ + Params: { mmsi: string }; + Querystring: { minutes?: string }; +}>("/api/ais-target/:mmsi/track", async (req, reply) => { + const mmsiRaw = req.params.mmsi; + const mmsi = Number(mmsiRaw); + if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) { + return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" }); + } + + const minutesRaw = req.query.minutes ?? "360"; + const minutes = Number(minutesRaw); + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) { + return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" }); + } + + const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE); + u.searchParams.set("minutes", String(minutes)); + + const controller = new AbortController(); + const timeoutMs = 20_000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); + const txt = await res.text(); + if (!res.ok) { + req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error"); + return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); + } + + reply.type("application/json").send(txt); + } catch (e) { + const name = e instanceof Error ? e.name : ""; + const isTimeout = name === "AbortError"; + req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed"); + return reply.code(isTimeout ? 504 : 502).send({ + success: false, + message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", + data: [], + errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", + }); + } finally { + clearTimeout(timeout); + } +}); + app.get("/zones", async (_req, reply) => { const zonesPath = path.resolve( process.cwd(), diff --git a/apps/web/package.json b/apps/web/package.json index e98224e..629c7e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@maptiler/weather": "^3.1.1", @@ -19,7 +20,10 @@ "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/src/app/styles.css b/apps/web/src/app/styles.css index ecb74c2..9e8c645 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -105,6 +105,7 @@ body { .map-area { position: relative; + background: #010610; } .sb { diff --git a/apps/web/src/entities/aisTarget/api/searchChnprmship.ts b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts new file mode 100644 index 0000000..272e3c7 --- /dev/null +++ b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts @@ -0,0 +1,32 @@ +import type { AisTargetSearchResponse } from '../model/types'; + +export async function searchChnprmship( + params: { minutes: number }, + signal?: AbortSignal, +): Promise { + const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin); + u.searchParams.set('minutes', String(params.minutes)); + + const res = await fetch(u, { signal, headers: { accept: 'application/json' } }); + const txt = await res.text(); + let json: unknown = null; + try { + json = JSON.parse(txt); + } catch { + // ignore + } + if (!res.ok) { + const msg = + json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string' + ? (json as { message: string }).message + : txt.slice(0, 200) || res.statusText; + throw new Error(`chnprmship API failed: ${res.status} ${msg}`); + } + + if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload'); + const parsed = json as AisTargetSearchResponse; + if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false'); + + return parsed; +} diff --git a/apps/web/src/entities/vesselTrack/api/fetchTrack.ts b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts new file mode 100644 index 0000000..910b203 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts @@ -0,0 +1,32 @@ +import type { TrackResponse } from '../model/types'; + +const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + +export async function fetchVesselTrack( + mmsi: number, + minutes: number, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + + const combinedSignal = signal ?? controller.signal; + + try { + const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`; + const res = await fetch(url, { + signal: combinedSignal, + headers: { accept: 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`); + } + + const json = (await res.json()) as TrackResponse; + return json; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts new file mode 100644 index 0000000..34214c7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts @@ -0,0 +1,115 @@ +import { haversineNm } from '../../../shared/lib/geo/haversineNm'; +import type { ActiveTrack, NormalizedTrip } from '../model/types'; + +/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */ +export function normalizeTrip( + track: ActiveTrack, + color: [number, number, number], +): NormalizedTrip { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length === 0) { + return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color }; + } + + const baseEpoch = new Date(sorted[0].messageTimestamp).getTime(); + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const pt of sorted) { + path.push([pt.lon, pt.lat]); + // 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋 + timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000); + } + + return { + path, + timestamps, + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + color, + }; +} + +/** Globe 전용 — LineString GeoJSON */ +export function buildTrackLineGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length < 2) { + return { type: 'FeatureCollection', features: [] }; + } + + let totalDistanceNm = 0; + const coordinates: [number, number][] = []; + for (let i = 0; i < sorted.length; i++) { + const pt = sorted[i]; + coordinates.push([pt.lon, pt.lat]); + if (i > 0) { + const prev = sorted[i - 1]; + totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon); + } + } + + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + pointCount: sorted.length, + minutes: track.minutes, + totalDistanceNm: Math.round(totalDistanceNm * 100) / 100, + }, + geometry: { type: 'LineString', coordinates }, + }, + ], + }; +} + +/** Globe+Mercator 공용 — Point GeoJSON */ +export function buildTrackPointsGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + return { + type: 'FeatureCollection', + features: sorted.map((pt, index) => ({ + type: 'Feature' as const, + properties: { + mmsi: pt.mmsi, + name: pt.name, + sog: pt.sog, + cog: pt.cog, + heading: pt.heading, + status: pt.status, + messageTimestamp: pt.messageTimestamp, + index, + }, + geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] }, + })), + }; +} + +export function getTrackTimeRange(trip: NormalizedTrip): { + minTime: number; + maxTime: number; + durationSec: number; +} { + if (trip.timestamps.length === 0) { + return { minTime: 0, maxTime: 0, durationSec: 0 }; + } + const minTime = trip.timestamps[0]; + const maxTime = trip.timestamps[trip.timestamps.length - 1]; + return { minTime, maxTime, durationSec: maxTime - minTime }; +} diff --git a/apps/web/src/entities/vesselTrack/model/types.ts b/apps/web/src/entities/vesselTrack/model/types.ts new file mode 100644 index 0000000..96df8d7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/model/types.ts @@ -0,0 +1,39 @@ +export interface TrackPoint { + mmsi: number; + name: string; + lat: number; + lon: number; + heading: number; + sog: number; + cog: number; + rot: number; + length: number; + width: number; + draught: number; + status: string; + messageTimestamp: string; + receivedDate: string; + source: string; +} + +export interface TrackResponse { + success: boolean; + message: string; + data: TrackPoint[]; +} + +export interface ActiveTrack { + mmsi: number; + minutes: number; + points: TrackPoint[]; + fetchedAt: number; +} + +/** TripsLayer용 정규화 데이터 */ +export interface NormalizedTrip { + path: [number, number][]; + timestamps: number[]; + mmsi: number; + name: string; + color: [number, number, number]; +} diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index 8524e65..59b1aff 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; +import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship"; import type { AisTarget } from "../../entities/aisTarget/model/types"; export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; @@ -17,14 +18,21 @@ export type AisPollingSnapshot = { }; export type AisPollingOptions = { - initialMinutes?: number; - bootstrapMinutes?: number; + /** 초기 chnprmship API 호출 시 minutes (기본 120) */ + chnprmshipMinutes?: number; + /** 주기적 폴링 시 search API minutes (기본 2) */ incrementalMinutes?: number; + /** 폴링 주기 ms (기본 60_000) */ intervalMs?: number; + /** 보존 기간 (기본 chnprmshipMinutes) */ retentionMinutes?: number; + /** incremental 폴링 시 bbox 필터 */ bbox?: string; + /** incremental 폴링 시 중심 경도 */ centerLon?: number; + /** incremental 폴링 시 중심 위도 */ centerLat?: number; + /** incremental 폴링 시 반경(m) */ radiusMeters?: number; enabled?: boolean; }; @@ -112,11 +120,10 @@ function pruneStore(store: Map, retentionMinutes: number, bbo } export function useAisTargetPolling(opts: AisPollingOptions = {}) { - const initialMinutes = opts.initialMinutes ?? 60; - const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; - const incrementalMinutes = opts.incrementalMinutes ?? 1; + const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120; + const incrementalMinutes = opts.incrementalMinutes ?? 2; const intervalMs = opts.intervalMs ?? 60_000; - const retentionMinutes = opts.retentionMinutes ?? initialMinutes; + const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes; const enabled = opts.enabled ?? true; const bbox = opts.bbox; const centerLon = opts.centerLon; @@ -146,50 +153,60 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const controller = new AbortController(); const generation = ++generationRef.current; - async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { + function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) { + if (cancelled || generation !== generationRef.current) return; + + const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); + const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); + const total = storeRef.current.size; + + setSnapshot({ + status: "ready", + error: null, + lastFetchAt: new Date().toISOString(), + lastFetchMinutes: minutes, + lastMessage: res.message, + total, + lastUpserted: upserted, + lastInserted: inserted, + lastDeleted: deleted, + }); + setRev((r) => r + 1); + } + + async function runInitial(minutes: number) { try { - setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); - - const res = await searchAisTargets( - { - minutes, - bbox, - centerLon, - centerLat, - radiusMeters, - }, - controller.signal, - ); - if (cancelled || generation !== generationRef.current) return; - - const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); - const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); - const total = storeRef.current.size; - const lastFetchAt = new Date().toISOString(); - - setSnapshot({ - status: "ready", - error: null, - lastFetchAt, - lastFetchMinutes: minutes, - lastMessage: res.message, - total, - lastUpserted: upserted, - lastInserted: inserted, - lastDeleted: deleted, - }); - setRev((r) => r + 1); + setSnapshot((s) => ({ ...s, status: "loading", error: null })); + const res = await searchChnprmship({ minutes }, controller.signal); + applyResult(res, minutes); } catch (e) { if (cancelled || generation !== generationRef.current) return; setSnapshot((s) => ({ ...s, - status: context === "incremental" ? s.status : "error", + status: "error", error: e instanceof Error ? e.message : String(e), })); } } - // Reset store when polling config changes (bbox, retention, etc). + async function runIncremental(minutes: number) { + try { + setSnapshot((s) => ({ ...s, error: null })); + const res = await searchAisTargets( + { minutes, bbox, centerLon, centerLat, radiusMeters }, + controller.signal, + ); + applyResult(res, minutes); + } catch (e) { + if (cancelled || generation !== generationRef.current) return; + setSnapshot((s) => ({ + ...s, + error: e instanceof Error ? e.message : String(e), + })); + } + } + + // Reset store when polling config changes. storeRef.current = new Map(); setSnapshot({ status: "loading", @@ -204,12 +221,11 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); - void run(bootstrapMinutes, "bootstrap"); - if (bootstrapMinutes !== initialMinutes) { - void run(initialMinutes, "initial"); - } + // 초기 로드: chnprmship API 1회 호출 + void runInitial(chnprmshipMinutes); - const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); + // 주기적 폴링: search API로 incremental 업데이트 + const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs); return () => { cancelled = true; @@ -217,8 +233,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { window.clearInterval(id); }; }, [ - initialMinutes, - bootstrapMinutes, + chnprmshipMinutes, incrementalMinutes, intervalMs, retentionMinutes, 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/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index a96a64f..a5e4a1f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../../shared/auth"; +import { usePersistedState } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; @@ -19,6 +20,7 @@ import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList"; import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel"; import { MapLegend } from "../../widgets/legend/MapLegend"; import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D"; +import type { MapViewState } from "../../widgets/map3d/types"; import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; import { Topbar } from "../../widgets/topbar/Topbar"; @@ -33,6 +35,10 @@ import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlay 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 { buildLegacyHitMap, computeCountsByType, @@ -52,13 +58,6 @@ const AIS_CENTER = { radiusMeters: 2_000_000, }; -function fmtLocal(iso: string | null) { - if (!iso) return "-"; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString("ko-KR", { hour12: false }); -} - type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] type FleetRelationSortMode = "count" | "range"; @@ -97,11 +96,10 @@ export function DashboardPage() { const [apiBbox, setApiBbox] = useState(undefined); const { targets, snapshot } = useAisTargetPolling({ - initialMinutes: 60, - bootstrapMinutes: 10, + chnprmshipMinutes: 120, incrementalMinutes: 2, intervalMs: 60_000, - retentionMinutes: 90, + retentionMinutes: 120, bbox: useApiBbox ? apiBbox : undefined, centerLon: useApiBbox ? undefined : AIS_CENTER.lon, centerLat: useApiBbox ? undefined : AIS_CENTER.lat, @@ -114,55 +112,83 @@ export function DashboardPage() { const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const [typeEnabled, setTypeEnabled] = useState>({ - PT: true, - "PT-S": true, - GN: true, - OT: true, - PS: true, - FC: true, - }); - const [showTargets, setShowTargets] = useState(true); - const [showOthers, setShowOthers] = useState(false); + const uid = user?.id ?? null; + const [typeEnabled, setTypeEnabled] = usePersistedState>( + uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, + ); + const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); + const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [baseMap, _setBaseMap] = useState("enhanced"); - const [projection, setProjection] = useState("mercator"); - const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); + // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 + const [projection, setProjection] = useState('mercator'); + const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); - const [overlays, setOverlays] = useState({ - pairLines: true, - pairRange: true, - fcLines: true, - zones: true, - fleetCircles: true, - predictVectors: true, - shipLabels: true, - subcables: false, + const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { + pairLines: true, pairRange: true, fcLines: true, zones: true, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, }); - const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); + const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', "count"); - const [alarmKindEnabled, setAlarmKindEnabled] = useState>(() => { - return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record; - }); + const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( + uid, 'alarmKindEnabled', + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + ); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); - const [settings, setSettings] = useState({ - showShips: true, - showDensity: false, - showSeamark: false, + // 항적 (vessel track) + 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 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 { + trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); + } + } catch (e) { + trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); + } + }, [targets]); + + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { + showShips: true, showDensity: false, showSeamark: false, }); + const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); + // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화 + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); + const handleProjectionLoadingChange = useCallback((loading: boolean) => { + setIsProjectionLoading(loading); + }, []); + const showMapLoader = isProjectionLoading; + // globe 레이어 미준비 또는 전환 중일 때 토글 비활성화 + const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; - const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false })); + const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); useEffect(() => { - const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000); + const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000); return () => window.clearInterval(id); }, []); @@ -377,10 +403,10 @@ export function DashboardPage() { 지도 표시 설정
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} - title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소" - style={{ fontSize: 9, padding: "2px 8px" }} + className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`} + onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))} + title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"} + style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }} > 3D
@@ -557,7 +583,7 @@ export function DashboardPage() {
최근 fetch
- {fmtLocal(snapshot.lastFetchAt)}{" "} + {fmtIsoFull(snapshot.lastFetchAt)}{" "} ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) @@ -581,7 +607,7 @@ export function DashboardPage() { / {targetsInScope.length}
생성시각
-
{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}
+
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}
)}
@@ -692,7 +718,7 @@ export function DashboardPage() {
- {isProjectionLoading ? ( + {showMapLoader ? (
@@ -724,7 +750,8 @@ export function DashboardPage() { fcLinks={fcLinksForMap} fleetCircles={fleetCirclesForMap} fleetFocus={fleetFocus} - onProjectionLoadingChange={setIsProjectionLoading} + onProjectionLoadingChange={handleProjectionLoadingChange} + onGlobeShipsReady={setIsGlobeShipsReady} onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))} onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} @@ -743,7 +770,15 @@ export function DashboardPage() { onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} onMapReady={handleMapReady} + initialView={mapView} + onViewStateChange={setMapView} + activeTrack={null} + trackContextMenu={trackContextMenu} + onRequestTrack={handleRequestTrack} + onCloseTrackMenu={handleCloseTrackMenu} + onOpenTrackMenu={handleOpenTrackMenu} /> + (key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeStorage(key: string, value: T): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // quota exceeded or unavailable — silent + } +} + +function resolveDefault(d: T | (() => T)): T { + return typeof d === 'function' ? (d as () => T)() : d; +} + +/** + * useState와 동일한 API, localStorage 자동 동기화. + * + * @param userId null이면 일반 useState처럼 동작 (비영속) + * @param name 설정 이름 (e.g. 'typeEnabled') + * @param defaultValue 초기값 또는 lazy initializer + * @param debounceMs localStorage 쓰기 디바운스 (기본 300ms) + */ +export function usePersistedState( + userId: number | null, + name: string, + defaultValue: T | (() => T), + debounceMs = 300, +): [T, Dispatch>] { + const resolved = resolveDefault(defaultValue); + + const [state, setState] = useState(() => { + if (userId == null) return resolved; + return readStorage(buildKey(userId, name), resolved); + }); + + const timerRef = useRef | null>(null); + const stateRef = useRef(state); + const userIdRef = useRef(userId); + const nameRef = useRef(name); + + stateRef.current = state; + userIdRef.current = userId; + nameRef.current = name; + + // userId 변경 시 해당 사용자의 저장값 재로드 + useEffect(() => { + if (userId == null) return; + const stored = readStorage(buildKey(userId, name), resolved); + setState(stored); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + // debounced write + useEffect(() => { + if (userId == null) return; + const key = buildKey(userId, name); + + if (timerRef.current != null) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + writeStorage(key, state); + timerRef.current = null; + }, debounceMs); + + return () => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [state, userId, name, debounceMs]); + + // unmount 시 pending write flush + useEffect(() => { + return () => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (userIdRef.current != null) { + writeStorage(buildKey(userIdRef.current, nameRef.current), stateRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [state, setState]; +} diff --git a/apps/web/src/shared/lib/datetime.ts b/apps/web/src/shared/lib/datetime.ts new file mode 100644 index 0000000..5d532c2 --- /dev/null +++ b/apps/web/src/shared/lib/datetime.ts @@ -0,0 +1,50 @@ +/** + * 타임존 & 날짜 포맷 유틸리티 + * + * 현재 KST 고정. 추후 토글 필요 시 DISPLAY_TZ 상수만 변경. + */ + +/** 표시용 타임존. 'UTC' | 'Asia/Seoul' 등 IANA tz 문자열. */ +export const DISPLAY_TZ = 'Asia/Seoul' as const; + +/** 표시 레이블 (예: "KST") */ +export const DISPLAY_TZ_LABEL = 'KST' as const; + +/* ── 포맷 함수 ─────────────────────────────────────────────── */ + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** DISPLAY_TZ 기준으로 Date → "YYYY년 MM월 DD일 HH시 mm분 ss초" */ +export function fmtDateTimeFull(date: Date): string { + const parts = new Intl.DateTimeFormat('ko-KR', { + timeZone: DISPLAY_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date); + + const p: Record = {}; + for (const { type, value } of parts) p[type] = value; + + return `${p.year}년 ${p.month}월 ${p.day}일 ${p.hour}시 ${pad2(Number(p.minute))}분 ${pad2(Number(p.second))}초`; +} + +/** ISO 문자열 → "YYYY년 MM월 DD일 HH시 mm분 ss초" (파싱 실패 시 fallback) */ +export function fmtIsoFull(iso: string | null | undefined): string { + if (!iso) return '-'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return fmtDateTimeFull(d); +} + +/** ISO 문자열 → "HH:mm:ss" (시간만) */ +export function fmtIsoTime(iso: string | null | undefined): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleTimeString('ko-KR', { timeZone: DISPLAY_TZ, hour12: false }); +} diff --git a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx index 1f1bc51..7df8139 100644 --- a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx +++ b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx @@ -1,5 +1,6 @@ import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; +import { fmtIsoFull } from "../../shared/lib/datetime"; type Props = { target: AisTarget; @@ -85,11 +86,11 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
Msg TS - {t.messageTimestamp || "-"} + {fmtIsoFull(t.messageTimestamp)}
Received - {t.receivedDate || "-"} + {fmtIsoFull(t.receivedDate)}
); diff --git a/apps/web/src/widgets/aisTargetList/AisTargetList.tsx b/apps/web/src/widgets/aisTargetList/AisTargetList.tsx index d9b79af..67fa2c1 100644 --- a/apps/web/src/widgets/aisTargetList/AisTargetList.tsx +++ b/apps/web/src/widgets/aisTargetList/AisTargetList.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; import { matchLegacyVessel } from "../../entities/legacyVessel/lib"; +import { fmtIsoTime } from "../../shared/lib/datetime"; type SortMode = "recent" | "speed"; @@ -23,13 +24,6 @@ function getSpeedColor(sog: unknown) { return "#64748B"; } -function fmtLocalTime(iso: string | null | undefined) { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return String(iso); - return d.toLocaleTimeString("ko-KR", { hour12: false }); -} - export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) { const [q, setQ] = useState(""); const [mode, setMode] = useState("recent"); @@ -96,7 +90,7 @@ export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex const sel = selectedMmsi && t.mmsi === selectedMmsi; const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?"; const sc = getSpeedColor(t.sog); - const ts = fmtLocalTime(t.messageTimestamp); + const ts = fmtIsoTime(t.messageTimestamp); const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null; const legacyCode = legacy?.shipCode || ""; diff --git a/apps/web/src/widgets/info/VesselInfoPanel.tsx b/apps/web/src/widgets/info/VesselInfoPanel.tsx index 845640e..5920a9d 100644 --- a/apps/web/src/widgets/info/VesselInfoPanel.tsx +++ b/apps/web/src/widgets/info/VesselInfoPanel.tsx @@ -1,6 +1,7 @@ import { ZONE_META } from "../../entities/zone/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import { fmtIsoFull } from "../../shared/lib/datetime"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; @@ -75,7 +76,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
Msg TS - {v.messageTimestamp || "-"} + {fmtIsoFull(v.messageTimestamp)}
소유주 diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 543c973..9a5f48d 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,7 +26,13 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; +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'; @@ -67,14 +73,15 @@ export function Map3D({ onClickCable, mapStyleSettings, onMapReady, + initialView, + onViewStateChange, + onGlobeShipsReady, + activeTrack = null, + trackContextMenu = null, + onRequestTrack, + onCloseTrackMenu, + onOpenTrackMenu, }: Props) { - void onHoverFleet; - void onClearFleetHover; - void onHoverMmsi; - void onClearMmsiHover; - void onHoverPair; - void onClearPairHover; - // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); const mapRef = useRef(null); @@ -92,6 +99,7 @@ export function Map3D({ // ── Hover state ────────────────────────────────────────────────────── const { + hoveredDeckMmsiSet: hoveredDeckMmsiArr, setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, setHoveredDeckFleetOwnerKey, @@ -190,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); @@ -225,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 => { @@ -284,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; @@ -325,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( @@ -353,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 @@ -402,48 +492,48 @@ 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, clearGlobeNativeLayers, pulseMapSync } = useMapInit( + const { ensureMercatorOverlay, pulseMapSync } = useMapInit( containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, baseMapRef, projectionRef, - { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, + { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange }, ); const reorderGlobeFeatureLayers = useProjectionToggle( mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, - { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, + { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, ); useBaseMapToggle( @@ -469,10 +559,11 @@ 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, }, ); @@ -497,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, @@ -524,6 +615,62 @@ export function Map3D({ }, ); + useTrackReplayLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState }, + ); + + // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 + // Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures + const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr); + useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onContextMenu = (e: MouseEvent) => { + e.preventDefault(); + if (!onOpenTrackMenu) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return; + + let mmsi: number | null = null; + + if (projectionRef.current === 'globe') { + // Globe: MapLibre 네이티브 레이어에서 쿼리 + const point: [number, number] = [e.offsetX, e.offsetY]; + const shipLayerIds = [ + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', + ].filter((id) => map.getLayer(id)); + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + if (shipLayerIds.length > 0) { + features = map.queryRenderedFeatures(point, { layers: shipLayerIds }); + } + } catch { /* ignore */ } + + if (features.length > 0) { + const props = features[0].properties || {}; + const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi); + if (Number.isFinite(raw) && raw > 0) mmsi = raw; + } + } else { + // Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용 + const hovered = hoveredDeckMmsiRef.current; + if (hovered.length > 0) mmsi = hovered[0]; + } + + if (mmsi == null || !legacyHits?.has(mmsi)) return; + + const target = shipByMmsi.get(mmsi); + const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`; + onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName }); + }; + container.addEventListener('contextmenu', onContextMenu); + return () => container.removeEventListener('contextmenu', onContextMenu); + }, [onOpenTrackMenu, legacyHits, shipByMmsi]); + useFlyTo( mapRef, projectionRef, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, @@ -537,5 +684,19 @@ export function Map3D({ onMapReady(mapRef.current); }, [mapSyncEpoch, onMapReady]); - return
; + return ( + <> +
+ {trackContextMenu && onRequestTrack && onCloseTrackMenu && ( + + )} + + ); } diff --git a/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx new file mode 100644 index 0000000..5cc0591 --- /dev/null +++ b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; + +interface Props { + x: number; + y: number; + mmsi: number; + vesselName: string; + onRequestTrack: (mmsi: number, minutes: number) => void; + onClose: () => void; +} + +const TRACK_OPTIONS = [ + { label: '6시간', minutes: 360 }, + { label: '12시간', minutes: 720 }, + { label: '1일', minutes: 1440 }, + { label: '3일', minutes: 4320 }, + { label: '5일', minutes: 7200 }, +] as const; + +const MENU_WIDTH = 180; +const MENU_PAD = 8; + +export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) { + const ref = useRef(null); + + // 화면 밖 보정 + const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD); + const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD; + const top = Math.min(y, maxTop); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onScroll = () => onClose(); + + window.addEventListener('keydown', onKey); + window.addEventListener('mousedown', onClick, true); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('mousedown', onClick, true); + window.removeEventListener('scroll', onScroll, true); + }; + }, [onClose]); + + const handleSelect = (minutes: number) => { + onRequestTrack(mmsi, minutes); + onClose(); + }; + + return ( +
+ {/* Header */} +
+ {vesselName} +
+ + {/* 항적조회 항목 */} +
+ 항적조회 +
+ + {TRACK_OPTIONS.map((opt) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/widgets/map3d/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/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts index 9844603..b9f3287 100644 --- a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -110,7 +110,7 @@ export function useBaseMapToggle( if (!map) return; if (showSeamark) { try { - ensureSeamarkOverlay(map, 'bathymetry-lines'); + ensureSeamarkOverlay(map, 'bathymetry-lines-coarse'); map.setPaintProperty('seamark', 'raster-opacity', 0.85); } catch { // ignore until style is ready diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 4ab3b49..1eefe43 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -50,6 +50,13 @@ import { getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; +import { getCachedShipIcon } from '../lib/shipIconCache'; + +// NOTE: +// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). +// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. +const ENABLE_GLOBE_DECK_OVERLAYS = false; + export function useDeckLayers( mapRef: MutableRefObject, @@ -59,6 +66,7 @@ export function useDeckLayers( opts: { projection: MapProjectionId; settings: Map3DSettings; + trackReplayDeckLayers: unknown[]; shipLayerData: AisTarget[]; shipOverlayLayerData: AisTarget[]; shipData: AisTarget[]; @@ -96,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, @@ -374,7 +382,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -397,7 +405,7 @@ export function useDeckLayers( pickable: false, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -442,7 +450,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -458,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 })); } @@ -478,10 +486,12 @@ export function useDeckLayers( if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); } - const normalizedLayers = sanitizeDeckLayerList(layers); + const normalizedBaseLayers = sanitizeDeckLayerList(layers); + const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers); + const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]); const deckProps = { layers: normalizedLayers, getTooltip: (info: PickingInfo) => { @@ -546,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, @@ -576,6 +581,7 @@ export function useDeckLayers( overlays.fleetCircles, settings.showDensity, settings.showShips, + trackReplayDeckLayers, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, @@ -595,6 +601,16 @@ export function useDeckLayers( const deckTarget = globeDeckLayerRef.current; if (!deckTarget) return; + if (!ENABLE_GLOBE_DECK_OVERLAYS) { + try { + deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never); + } catch { + // ignore + } + return; + } + + const overlayParams = GLOBE_OVERLAY_PARAMS; const globeLayers: unknown[] = []; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts index 96892ae..c31a3ab 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -118,7 +118,7 @@ export function useGlobeInteraction( }); } - if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') { + if (layerId === 'fleet-circles-ml') { return getFleetCircleTooltipHtml({ ownerKey: String(props.ownerKey ?? ''), ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), @@ -184,9 +184,9 @@ 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', 'fleet-circles-ml-fill', + 'fleet-circles-ml', 'pair-range-ml', 'zones-fill', 'zones-line', 'zones-label', ].filter((id) => map.getLayer(id)); @@ -211,9 +211,9 @@ 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-fill', 'fleet-circles-ml', + 'fleet-circles-ml', 'zones-fill', 'zones-line', 'zones-label', ]; @@ -229,10 +229,14 @@ 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' || layerId === 'fleet-circles-ml-fill'; + const isFleetLayer = layerId === 'fleet-circles-ml'; const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; if (isShipLayer) { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index dad2779..b2261d6 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -11,7 +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, FLEET_FILL_ML_HL, + FLEET_FILL_ML_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL, } from '../constants'; import { makeUniqueSorted } from '../lib/setUtils'; @@ -28,6 +28,7 @@ import { } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; import { dashifyLine } from '../lib/dashifyLine'; export function useGlobeOverlays( @@ -60,17 +61,14 @@ export function useGlobeOverlays( const layerId = 'pair-lines-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; 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; } @@ -132,11 +130,7 @@ export function useGlobeOverlays( console.warn('Pair lines layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); @@ -148,7 +142,7 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); // FC lines useEffect(() => { @@ -159,17 +153,15 @@ export function useGlobeOverlays( const layerId = 'fc-lines-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; 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,11 +227,7 @@ export function useGlobeOverlays( console.warn('FC lines layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); @@ -251,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(() => { @@ -263,30 +259,31 @@ export function useGlobeOverlays( const layerId = 'fleet-circles-ml'; const fillLayerId = 'fleet-circles-ml-fill'; + // fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인 + // 라인만으로 fleet circle 시각화 충분 + const remove = () => { - try { - if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); - } catch { - // ignore - } - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + 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', @@ -306,22 +303,19 @@ export function useGlobeOverlays( const fcFill: GeoJSON.FeatureCollection = { type: 'FeatureCollection', - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { + features: circles + .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) + .map((c) => ({ type: 'Feature', - id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, - geometry: { type: 'Polygon', coordinates: [ring] }, - properties: { - type: 'fleet-fill', - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - highlighted: 0, + id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), + geometry: { + type: 'Polygon', + coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], }, - }; - }), + properties: { + ownerKey: c.ownerKey, + }, + })), }; try { @@ -338,34 +332,7 @@ export function useGlobeOverlays( if (existingFill) existingFill.setData(fcFill); else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); } catch (e) { - console.warn('Fleet circles source setup failed:', e); - return; - } - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: 'fill', - source: fillSrcId, - layout: { visibility: 'visible' }, - paint: { - 'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never, - 'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles fill layer add failed:', e); - } - } else { - try { - map.setLayoutProperty(fillLayerId, 'visibility', 'visible'); - } catch { - // ignore - } + console.warn('Fleet circles fill source setup failed:', e); } if (!map.getLayer(layerId)) { @@ -388,11 +355,28 @@ export function useGlobeOverlays( console.warn('Fleet circles layer add failed:', e); } } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + if (!map.getLayer(fillLayerId)) { try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore + 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(); @@ -404,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(() => { @@ -415,17 +407,14 @@ export function useGlobeOverlays( const layerId = 'pair-range-ml'; const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'none'); }; 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; } @@ -503,11 +492,7 @@ export function useGlobeOverlays( console.warn('Pair range layer add failed:', e); } } else { - try { - map.setLayoutProperty(layerId, 'visibility', 'visible'); - } catch { - // ignore - } + guardedSetVisibility(map, layerId, 'visible'); } kickRepaint(map); @@ -518,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 @@ -593,10 +578,7 @@ export function useGlobeOverlays( } try { - if (map.getLayer('fleet-circles-ml-fill')) { - map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never); - map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never); - } + // fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인) if (map.getLayer('fleet-circles-ml')) { map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index b5b16fb..8cbc4ee 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, type MutableRefObject } from 'react'; +import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; @@ -25,6 +25,7 @@ import { ensureFallbackShipImage, } from '../lib/globeShipIcon'; import { clampNumber } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; export function useGlobeShips( mapRef: MutableRefObject, @@ -48,18 +49,81 @@ export function useGlobeShips( selectedMmsi: number | null; isBaseHighlightedMmsi: (mmsi: number) => boolean; hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + onGlobeShipsReady?: (ready: boolean) => void; }, ) { const { projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, + onGlobeShipsReady, } = opts; const globeShipsEpochRef = useRef(-1); - const globeShipIconLoadingRef = useRef(false); const globeHoverShipSignatureRef = useRef(''); + // Globe GeoJSON을 projection과 무관하게 항상 사전 계산 + // Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱 + const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection => { + return { + type: 'FeatureCollection', + features: shipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || ''; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading }); + const shipHeading = isAnchored ? 0 : heading; + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const highlighted = isBaseHighlightedMmsi(t.mmsi); + const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); + const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); + const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); + return { + type: 'Feature' as const, + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + labelName, + cog: shipHeading, + heading: shipHeading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + isAnchored: isAnchored ? 1 : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, + iconSize18: iconSize18 * iconScale, + sizeScale, + selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, + permitted: legacy ? 1 : 0, + code: legacy?.shipCode || '', + }, + }; + }), + }; + }, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]); + // Ship name labels in mercator useEffect(() => { const map = mapRef.current; @@ -206,113 +270,58 @@ 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'; - const remove = () => { - for (const id of [labelId, symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) + const hide = () => { + for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { + guardedSetVisibility(map, id, 'none'); } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; const ensureImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - if (globeShipIconLoadingRef.current) return; if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - - const addFallbackImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - kickRepaint(map); - }; - - let fallbackTimer: ReturnType | null = null; - try { - globeShipIconLoadingRef.current = true; - fallbackTimer = window.setTimeout(() => { - addFallbackImage(); - }, 80); - void map - .loadImage('/assets/ship.svg') - .then((response) => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - - const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; - if (!loadedImage) { - addFallbackImage(); - return; - } - - try { - if (map.hasImage(imgId)) { - try { - map.removeImage(imgId); - } catch { - // ignore - } - } - if (map.hasImage(anchoredImgId)) { - try { - map.removeImage(anchoredImgId); - } catch { - // ignore - } - } - map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); - map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); - kickRepaint(map); - } catch (e) { - console.warn('Ship icon image add failed:', e); - } - }) - .catch(() => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - addFallbackImage(); - }); - } catch (e) { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - try { - addFallbackImage(); - } catch (fallbackError) { - console.warn('Ship icon image setup failed:', e, fallbackError); - } - } + kickRepaint(map); }; const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== 'globe' || !settings.showShips) { - remove(); + if (!settings.showShips) { + hide(); + onGlobeShipsReady?.(false); return; } + // 빠른 visibility 토글 — projectionBusy 중에도 실행 + // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출 + // → 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) || map.getLayer(symbolLiteId)) { + const changed = + map.getLayoutProperty(symbolId, 'visibility') !== visibility || + map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; + if (changed) { + for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { + guardedSetVisibility(map, id, visibility); + } + if (projection === 'globe') kickRepaint(map); + } + guardedSetVisibility(map, labelId, labelVisibility); + } + + // 데이터 업데이트는 projectionBusy 중에는 차단 + if (projectionBusyRef.current) { + // 레이어가 이미 존재하면 ready 상태 유지 + if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); + return; + } + if (!map.isStyleLoaded()) return; + if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.current = mapSyncEpoch; } @@ -323,69 +332,8 @@ export function useGlobeShips( console.warn('Ship icon image setup failed:', e); } - const globeShipData = shipData; - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: globeShipData.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const labelName = - legacy?.shipNameCn || - legacy?.shipNameRoman || - t.name || - ''; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const isAnchored = isAnchoredShip({ - sog: t.sog, - cog: t.cog, - heading: t.heading, - }); - const shipHeading = isAnchored ? 0 : heading; - const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); - const selectedScale = selected ? 1.08 : 1; - const highlightScale = highlighted ? 1.06 : 1; - const iconScale = selected ? selectedScale : highlightScale; - const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); - const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); - const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); - const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); - return { - type: 'Feature', - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || '', - labelName, - cog: shipHeading, - heading: shipHeading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - isAnchored: isAnchored ? 1 : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: iconSize3 * iconScale, - iconSize7: iconSize7 * iconScale, - iconSize10: iconSize10 * iconScale, - iconSize14: iconSize14 * iconScale, - iconSize18: iconSize18 * iconScale, - sizeScale, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, - permitted: legacy ? 1 : 0, - code: legacy?.shipCode || '', - }, - }; - }), - }; + // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) + const geojson = globeShipGeoJson; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; @@ -396,8 +344,19 @@ export function useGlobeShips( return; } - const visibility = settings.showShips ? 'visible' : 'none'; 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 { @@ -434,35 +393,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship halo layer add failed:', e); } - } else { - try { - map.setLayoutProperty(haloId, 'visibility', visibility); - map.setLayoutProperty(haloId, 'circle-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, - ['==', ['get', 'permitted'], 1], 110, - ['==', ['get', 'selected'], 1], 60, - ['==', ['get', 'highlighted'], 1], 55, - 20, - ] as never); - map.setPaintProperty(haloId, 'circle-color', [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)', - ['coalesce', ['get', 'shipColor'], '#64748b'], - ] as never); - map.setPaintProperty(haloId, 'circle-opacity', [ - 'case', - ['==', ['get', 'selected'], 1], 0.38, - ['==', ['get', 'highlighted'], 1], 0.34, - 0.16, - ] as never); - map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR); - } catch { - // ignore - } } + // halo: data-driven expressions are static — visibility handled by fast toggle above if (!map.getLayer(outlineId)) { try { @@ -508,36 +440,78 @@ export function useGlobeShips( } catch (e) { console.warn('Ship outline layer add failed:', e); } - } else { + } + // outline: data-driven expressions are static — visibility handled by fast toggle + + if (!map.getLayer(symbolLiteId)) { try { - map.setLayoutProperty(outlineId, 'visibility', visibility); - map.setLayoutProperty(outlineId, 'circle-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, - ['==', ['get', 'permitted'], 1], 120, - ['==', ['get', 'selected'], 1], 70, - ['==', ['get', 'highlighted'], 1], 65, - 30, - ] as never); - map.setPaintProperty(outlineId, 'circle-stroke-color', [ - 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, - ] as never); - map.setPaintProperty(outlineId, 'circle-stroke-width', [ - 'case', - ['==', ['get', 'selected'], 1], 3.4, - ['==', ['get', 'highlighted'], 1], 2.7, - ['==', ['get', 'permitted'], 1], 1.8, - 0.7, - ] as never); - } catch { - // ignore + 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 { @@ -546,6 +520,7 @@ export function useGlobeShips( id: symbolId, type: 'symbol', source: srcId, + filter: priorityFilter as never, layout: { visibility, 'symbol-sort-key': [ @@ -586,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, @@ -598,31 +573,9 @@ export function useGlobeShips( } catch (e) { console.warn('Ship symbol layer add failed:', e); } - } else { - try { - map.setLayoutProperty(symbolId, 'visibility', visibility); - map.setLayoutProperty(symbolId, 'symbol-sort-key', [ - 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, - ['==', ['get', 'permitted'], 1], 130, - ['==', ['get', 'selected'], 1], 80, - ['==', ['get', 'highlighted'], 1], 75, - 45, - ] as never); - map.setPaintProperty(symbolId, 'icon-opacity', [ - 'case', - ['==', ['get', 'permitted'], 1], 1, - ['==', ['get', 'selected'], 1], 0.86, - ['==', ['get', 'highlighted'], 1], 0.82, - 0.66, - ] as never); - } catch { - // ignore - } } + // symbol: data-driven expressions are static — visibility handled by fast toggle - const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; const labelFilter = [ 'all', ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], @@ -672,17 +625,14 @@ export function useGlobeShips( } catch (e) { console.warn('Ship label layer add failed:', e); } - } else { - try { - map.setLayoutProperty(labelId, 'visibility', labelVisibility); - map.setFilter(labelId, labelFilter as never); - map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); - } catch { - // ignore - } } + // label: filter/text-field are static — visibility handled by fast toggle - reorderGlobeFeatureLayers(); + // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) + onGlobeShipsReady?.(true); + if (projection === 'globe') { + reorderGlobeFeatureLayers(); + } kickRepaint(map); }; @@ -694,12 +644,12 @@ export function useGlobeShips( projection, settings.showShips, overlays.shipLabels, - shipData, - legacyHits, + globeShipGeoJson, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, + onGlobeShipsReady, ]); // Globe hover overlay ships @@ -713,22 +663,10 @@ export function useGlobeShips( const outlineId = 'ships-globe-hover-outline'; const symbolId = 'ships-globe-hover'; - const remove = () => { + const hideHover = () => { for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + guardedSetVisibility(map, id, 'none'); } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; const ensure = () => { @@ -736,7 +674,7 @@ export function useGlobeShips( if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { - remove(); + hideHover(); return; } @@ -751,7 +689,7 @@ export function useGlobeShips( const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); if (hovered.length === 0) { - remove(); + hideHover(); return; } const hoverSignature = hovered @@ -968,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/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index eaed3df..5875d89 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -2,12 +2,11 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t import maplibregl, { type StyleSpecification } from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; -import type { BaseMapId, MapProjectionId } from '../types'; -import { DECK_VIEW_ID } from '../constants'; +import type { BaseMapId, MapProjectionId, MapViewState } from '../types'; +import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { ensureSeamarkOverlay } from '../layers/seamark'; import { resolveMapStyle } from '../layers/bathymetry'; -import { clearGlobeNativeLayers } from '../lib/layerHelpers'; export function useMapInit( containerRef: MutableRefObject, @@ -23,10 +22,14 @@ export function useMapInit( showSeamark: boolean; onViewBboxChange?: (bbox: [number, number, number, number]) => void; setMapSyncEpoch: Dispatch>; + initialView?: MapViewState | null; + onViewStateChange?: (view: MapViewState) => void; }, ) { const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; const showSeamarkRef = useRef(showSeamark); + const onViewStateChangeRef = useRef(opts.onViewStateChange); + useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]); useEffect(() => { showSeamarkRef.current = showSeamark; }, [showSeamark]); @@ -46,12 +49,6 @@ export function useMapInit( } }, []); - const clearGlobeNativeLayersCb = useCallback(() => { - const map = mapRef.current; - if (!map) return; - clearGlobeNativeLayers(map); - }, []); - const pulseMapSync = useCallback(() => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { @@ -65,6 +62,7 @@ export function useMapInit( let map: maplibregl.Map | null = null; let cancelled = false; + let viewSaveTimer: ReturnType | null = null; const controller = new AbortController(); (async () => { @@ -77,13 +75,14 @@ export function useMapInit( } if (cancelled || !containerRef.current) return; + const iv = opts.initialView; map = new maplibregl.Map({ container: containerRef.current, style, - center: [126.5, 34.2], - zoom: 7, - pitch: 45, - bearing: 0, + center: iv?.center ?? [126.5, 34.2], + zoom: iv?.zoom ?? 7, + pitch: iv?.pitch ?? 45, + bearing: iv?.bearing ?? 0, maxPitch: 85, dragRotate: true, pitchWithRotate: true, @@ -94,20 +93,73 @@ export function useMapInit( map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); + // MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제 + // symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제 + // globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제 + { + const origRender = (map as unknown as { _render: (arg?: number) => void })._render; + const origWarn = console.warn; + (map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) { + // globe 모드에서 scrollZoom의 easeTo around 경고 억제 + // eslint-disable-next-line no-console + console.warn = function (...args: unknown[]) { + if (typeof args[0] === 'string') { + const msg = args[0] as string; + if (msg.includes('Easing around a point')) return; + // vertex 경고는 디버그용으로 1회만 출력 후 억제 + if (msg.includes('Max vertices per segment')) { + origWarn.apply(console, args as [unknown, ...unknown[]]); + return; + } + } + origWarn.apply(console, args as [unknown, ...unknown[]]); + }; + try { + origRender.call(this, arg); + } catch (e) { + if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) { + return; + } + throw e; + } finally { + // eslint-disable-next-line no-console + console.warn = origWarn; + } + }; + } + + // Globe 모드 전환 시 지연을 제거하기 위해 ship.svg를 미리 로드 + { + const SHIP_IMG_ID = 'ship-globe-icon'; + const localMap = map; + void localMap + .loadImage('/assets/ship.svg') + .then((response) => { + if (cancelled || !localMap) return; + const img = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; + if (!img) return; + try { + if (!localMap.hasImage(SHIP_IMG_ID)) localMap.addImage(SHIP_IMG_ID, img, { pixelRatio: 2, sdf: true }); + if (!localMap.hasImage(ANCHORED_SHIP_ICON_ID)) localMap.addImage(ANCHORED_SHIP_ICON_ID, img, { pixelRatio: 2, sdf: true }); + } catch { + // ignore — fallback canvas icon이 useGlobeShips에서 사용됨 + } + }) + .catch(() => { + // ignore — useGlobeShips에서 fallback 처리 + }); + } + mapRef.current = map; setMapSyncEpoch((prev) => prev + 1); - if (projectionRef.current === 'mercator') { - const overlay = ensureMercatorOverlay(); - if (!overlay) return; - overlayRef.current = overlay; - } else { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: 'deck-globe', - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } + // 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거 + ensureMercatorOverlay(); + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); function applyProjection() { if (!map) return; @@ -123,8 +175,9 @@ export function useMapInit( onMapStyleReady(map, () => { applyProjection(); + // deck-globe를 항상 추가 (projection과 무관) const deckLayer = globeDeckLayerRef.current; - if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { + if (deckLayer && !map!.getLayer(deckLayer.id)) { try { map!.addLayer(deckLayer); } catch { @@ -133,7 +186,7 @@ export function useMapInit( } if (!showSeamarkRef.current) return; try { - ensureSeamarkOverlay(map!, 'bathymetry-lines'); + ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse'); } catch { // ignore } @@ -148,10 +201,38 @@ export function useMapInit( map.on('load', emitBbox); map.on('moveend', emitBbox); + // 60초 인터벌로 뷰 상태 저장 (mercator일 때만) + viewSaveTimer = setInterval(() => { + const cb = onViewStateChangeRef.current; + if (!cb || !map || projectionRef.current !== 'mercator') return; + const c = map.getCenter(); + cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); + }, 60_000); + map.once('load', () => { + // Globe 배경(타일 밖)을 심해 색상과 맞춰 타일 경계 seam을 비가시화 + try { + map!.setSky({ + 'sky-color': '#010610', + 'horizon-color': '#010610', + 'fog-color': '#010610', + 'fog-ground-blend': 1, + 'sky-horizon-blend': 0, + 'atmosphere-blend': 0, + }); + } catch { + // ignore + } + // 캔버스 배경도 심해색으로 통일 + try { + map!.getCanvas().style.background = '#010610'; + } catch { + // ignore + } + if (showSeamarkRef.current) { try { - ensureSeamarkOverlay(map!, 'bathymetry-lines'); + ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse'); } catch { // ignore } @@ -162,12 +243,22 @@ export function useMapInit( // ignore } } + // 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거 + setMapSyncEpoch((prev) => prev + 1); }); })(); return () => { cancelled = true; controller.abort(); + if (viewSaveTimer) clearInterval(viewSaveTimer); + + // 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음) + const cb = onViewStateChangeRef.current; + if (cb && map && projectionRef.current === 'mercator') { + const c = map.getCenter(); + cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); + } try { globeDeckLayerRef.current?.requestFinalize(); @@ -193,5 +284,5 @@ export function useMapInit( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; + return { ensureMercatorOverlay, pulseMapSync }; } diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 00feb3f..1165ab9 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) { if (layer.type !== 'symbol') continue; const layout = (layer as { layout?: Record }).layout; if (!layout?.['text-field']) continue; - if (layer.id === 'bathymetry-labels') continue; + if (layer.id.startsWith('bathymetry-labels')) continue; const textField = lang === 'local' ? ['get', 'name'] @@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) { if (id.startsWith('fc-')) continue; if (id.startsWith('fleet-')) continue; if (id.startsWith('predict-')) continue; + if (id.startsWith('vessel-track-')) continue; if (id === 'deck-globe') continue; const sourceLayer = String((layer as Record)['source-layer'] ?? ''); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); @@ -102,17 +103,19 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) { } function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { - if (!map.getLayer('bathymetry-fill')) return; const depth = ['to-number', ['get', 'depth']]; const sorted = [...stops].sort((a, b) => a.depth - b.depth); + if (sorted.length === 0) return; const expr: unknown[] = ['interpolate', ['linear'], depth]; - const deepest = sorted[0]; - if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5)); for (const s of sorted) { expr.push(s.depth, s.color); } + // 0m까지 확장 (최천층 stop이 0보다 깊으면) const shallowest = sorted[sorted.length - 1]; - if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8)); + if (shallowest.depth < 0) { + expr.push(0, lightenHex(shallowest.color, 1.8)); + } + if (!map.getLayer('bathymetry-fill')) return; try { map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never); } catch { @@ -122,7 +125,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) { const expr = DEPTH_FONT_SIZE_MAP[size]; - for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { + for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) { if (!map.getLayer(layerId)) continue; try { map.setLayoutProperty(layerId, 'text-size', expr); @@ -133,7 +136,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) { } function applyDepthFontColor(map: maplibregl.Map, color: string) { - for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { + for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) { if (!map.getLayer(layerId)) continue; try { map.setPaintProperty(layerId, 'text-color', color); 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 2b92733..c38cc38 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -3,9 +3,7 @@ import type maplibregl from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import type { MapProjectionId } from '../types'; -import { DECK_VIEW_ID } from '../constants'; import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; -import { removeLayerIfExists } from '../lib/layerHelpers'; export function useProjectionToggle( mapRef: MutableRefObject, @@ -15,14 +13,13 @@ export function useProjectionToggle( projectionBusyRef: MutableRefObject, opts: { projection: MapProjectionId; - clearGlobeNativeLayers: () => void; ensureMercatorOverlay: () => MapboxOverlay | null; onProjectionLoadingChange?: (loading: boolean) => void; pulseMapSync: () => void; setMapSyncEpoch: (updater: (prev: number) => number) => void; }, ): () => void { - const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; + const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); @@ -71,7 +68,7 @@ export function useProjectionToggle( if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; console.debug('Projection loading fallback timeout reached.'); endProjectionLoading(); - }, 4000); + }, 2000); }, [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], ); @@ -97,6 +94,15 @@ export function useProjectionToggle( 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', + 'track-replay-globe-path', + 'track-replay-globe-points', + 'track-replay-globe-virtual-ship', + 'track-replay-globe-virtual-label', 'zones-fill', 'zones-line', 'zones-label', @@ -106,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', @@ -176,45 +183,14 @@ export function useProjectionToggle( if (isTransition) setProjectionLoading(true); - const disposeMercatorOverlays = () => { - const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => { - if (!target) return; - try { - target.setProps({ layers: [] } as never); - } catch { - // ignore - } - try { - map.removeControl(target as never); - } catch { - // ignore - } - try { - target.finalize(); - } catch { - // ignore - } - if (toNull === 'base') { - overlayRef.current = null; - } else { - overlayInteractionRef.current = null; - } - }; - - disposeOne(overlayRef.current, 'base'); - disposeOne(overlayInteractionRef.current, 'interaction'); + // 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지 + const quietMercatorOverlays = () => { + try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } + try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } }; - const disposeGlobeDeckLayer = () => { - const current = globeDeckLayerRef.current; - if (!current) return; - removeLayerIfExists(map, current.id); - try { - current.requestFinalize(); - } catch { - // ignore - } - globeDeckLayerRef.current = null; + const quietGlobeDeckLayer = () => { + try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } }; const syncProjectionAndDeck = () => { @@ -236,11 +212,9 @@ export function useProjectionToggle( const shouldSwitchProjection = currentProjection !== next; if (projection === 'globe') { - disposeMercatorOverlays(); - clearGlobeNativeLayers(); + quietMercatorOverlays(); } else { - disposeGlobeDeckLayer(); - clearGlobeNativeLayers(); + quietGlobeDeckLayer(); } try { @@ -248,6 +222,17 @@ export function useProjectionToggle( map.setProjection({ type: next }); } map.setRenderWorldCopies(next !== 'globe'); + + // Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환 + try { + map.scrollZoom.disable(); + if (next === 'globe') { + map.scrollZoom.enable(); + } else { + map.scrollZoom.enable({ around: 'center' }); + } + } catch { /* ignore */ } + if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { retries += 1; window.requestAnimationFrame(() => syncProjectionAndDeck()); @@ -263,17 +248,9 @@ export function useProjectionToggle( console.warn('Projection switch failed:', e); } + // 양쪽 overlay가 항상 존재하므로 재생성 불필요 + // deck-globe가 map에서 빠져있을 경우에만 재추가 if (projection === 'globe') { - disposeGlobeDeckLayer(); - - if (!globeDeckLayerRef.current) { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: 'deck-globe', - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - const layer = globeDeckLayerRef.current; const layerId = layer?.id; if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { @@ -282,14 +259,8 @@ export function useProjectionToggle( } catch { // ignore } - if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } } } else { - disposeGlobeDeckLayer(); ensureMercatorOverlay(); } @@ -324,7 +295,7 @@ export function useProjectionToggle( if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); return reorderGlobeFeatureLayers; } diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 7fcc138..23c290c 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line'; const GLOW_ID = 'subcables-glow'; const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; +const HOVER_LABEL_ID = 'subcables-hover-label'; /* ── Paint defaults (used for layer creation + hover reset) ──────── */ const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; @@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [ type: 'line', sourceId: SRC_ID, paint: { - 'line-color': ['get', 'color'], + 'line-color': '#ffffff', 'line-opacity': 0, - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8], }, filter: ['==', ['get', 'id'], ''], layout: { 'line-cap': 'round', 'line-join': 'round' }, @@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [ }, minzoom: 4, }, + { + id: HOVER_LABEL_ID, + type: 'symbol', + sourceId: SRC_ID, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': 'rgba(0,0,0,0.85)', + 'text-halo-width': 2, + 'text-halo-blur': 0.5, + 'text-opacity': 0, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': ['get', 'name'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20], + 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + 'text-allow-overlap': true, + 'text-padding': 2, + 'text-rotation-alignment': 'map', + }, + filter: ['==', ['get', 'id'], ''], + minzoom: 2, + }, ]; export function useSubcablesLayer( @@ -250,42 +274,27 @@ export function useSubcablesLayer( } /* ── Hover highlight helper (paint-only mutations) ────────────────── */ +// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조 function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { + const noMatch = ['==', ['get', 'id'], ''] as never; if (hoveredId) { const matchExpr = ['==', ['get', 'id'], hoveredId]; - - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); - map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); - map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); - } if (map.getLayer(GLOW_ID)) { map.setFilter(GLOW_ID, matchExpr as never); - map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, matchExpr as never); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0); } } else { - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never); - map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never); - map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never); - } if (map.getLayer(GLOW_ID)) { - map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setFilter(GLOW_ID, noMatch); map.setPaintProperty(GLOW_ID, 'line-opacity', 0); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, noMatch); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0); } } } diff --git a/apps/web/src/widgets/map3d/hooks/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 new file mode 100644 index 0000000..ce734eb --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts @@ -0,0 +1,396 @@ +/** + * useVesselTrackLayer — 항적(Track) 렌더링 hook + * + * Mercator: TripsLayer 애니메이션 + ScatterplotLayer 포인트 + * Globe: MapLibre 네이티브 line + circle + symbol(arrow) + */ +import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types'; +import { + normalizeTrip, + buildTrackLineGeoJson, + buildTrackPointsGeoJson, + getTrackTimeRange, +} from '../../../entities/vesselTrack/lib/buildTrackGeoJson'; +import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips'; +import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers'; +import type { MapProjectionId } from '../types'; + +/* ── Constants ──────────────────────────────────────────────────────── */ +const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan +const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`; + +// Globe 네이티브 레이어/소스 ID +const LINE_SRC = 'vessel-track-line-src'; +const PTS_SRC = 'vessel-track-pts-src'; +const LINE_ID = 'vessel-track-line'; +const ARROW_ID = 'vessel-track-arrow'; +const HITAREA_ID = 'vessel-track-line-hitarea'; +const PTS_ID = 'vessel-track-pts'; +const PTS_HL_ID = 'vessel-track-pts-highlight'; + +// Mercator Deck.gl 레이어 ID +const DECK_PATH_ID = 'vessel-track-path'; +const DECK_TRIPS_ID = 'vessel-track-trips'; +const DECK_POINTS_ID = 'vessel-track-deck-pts'; + +/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */ +const GLOBE_LAYERS: NativeLayerSpec[] = [ + { + id: LINE_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { + 'line-color': TRACK_COLOR_CSS, + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4], + 'line-opacity': 0.8, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: HITAREA_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: ARROW_ID, + type: 'symbol', + sourceId: LINE_SRC, + paint: { + 'text-color': TRACK_COLOR_CSS, + 'text-opacity': 0.7, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': '▶', + 'text-size': 10, + 'symbol-spacing': 80, + 'text-rotation-alignment': 'map', + 'text-allow-overlap': true, + 'text-ignore-placement': true, + }, + }, + { + id: PTS_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5], + 'circle-color': TRACK_COLOR_CSS, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-opacity': 0.85, + }, + }, + { + id: PTS_HL_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12], + 'circle-color': '#ffffff', + 'circle-stroke-width': 2, + 'circle-stroke-color': TRACK_COLOR_CSS, + 'circle-opacity': 0, + }, + filter: ['==', ['get', 'index'], -1], + }, +]; + +/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */ +const ANIM_CYCLE_SEC = 20; + +/* ── Hook ──────────────────────────────────────────────────────────── */ +/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */ +export function useVesselTrackLayer( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + activeTrack: ActiveTrack | null; + projection: MapProjectionId; + mapSyncEpoch: number; + }, +) { + const { activeTrack, projection, mapSyncEpoch } = opts; + + /* ── 정규화 데이터 ── */ + const normalizedTrip = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return normalizeTrip(activeTrack, TRACK_COLOR); + }, [activeTrack]); + + const timeRange = useMemo(() => { + if (!normalizedTrip) return null; + return getTrackTimeRange(normalizedTrip); + }, [normalizedTrip]); + + /* ── Globe 네이티브 GeoJSON ── */ + const lineGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return buildTrackLineGeoJson(activeTrack); + }, [activeTrack]); + + const pointsGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length === 0) return null; + return buildTrackPointsGeoJson(activeTrack); + }, [activeTrack]); + + /* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */ + const globeSources = useMemo(() => [ + { id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } }, + { id: PTS_SRC, data: pointsGeoJson }, + ], [lineGeoJson, pointsGeoJson]); + + const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2; + + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: globeSources, + layers: GLOBE_LAYERS, + visible: isGlobeVisible, + beforeLayer: ['zones-fill', 'zones-line'], + }, + [lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch], + ); + + /* ── Globe 호버 툴팁 ── */ + const tooltipRef = useRef(null); + + const clearTooltip = useCallback(() => { + try { tooltipRef.current?.remove(); } catch { /* ignore */ } + tooltipRef.current = null; + }, []); + + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== 'globe' || !activeTrack) { + clearTooltip(); + return; + } + + const onMove = (e: maplibregl.MapMouseEvent) => { + if (projectionBusyRef.current || !map.isStyleLoaded()) { + clearTooltip(); + return; + } + + const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id)); + if (layers.length === 0) { clearTooltip(); return; } + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + features = map.queryRenderedFeatures(e.point, { layers }); + } catch { /* ignore */ } + + if (features.length === 0) { + clearTooltip(); + // 하이라이트 리셋 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + return; + } + + const feat = features[0]; + const props = feat.properties || {}; + const layerId = feat.layer?.id; + let tooltipHtml = ''; + + if (layerId === PTS_ID && props.index != null) { + tooltipHtml = getTrackPointTooltipHtml({ + name: String(props.name ?? ''), + sog: Number(props.sog), + cog: Number(props.cog), + heading: Number(props.heading), + status: String(props.status ?? ''), + messageTimestamp: String(props.messageTimestamp ?? ''), + }).html; + // 하이라이트 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8); + } + } catch { /* ignore */ } + } else if (layerId === HITAREA_ID) { + tooltipHtml = getTrackLineTooltipHtml({ + name: String(props.name ?? ''), + pointCount: Number(props.pointCount ?? 0), + minutes: Number(props.minutes ?? 0), + totalDistanceNm: Number(props.totalDistanceNm ?? 0), + }).html; + } + + if (!tooltipHtml) { clearTooltip(); return; } + + if (!tooltipRef.current) { + tooltipRef.current = new maplibregl.Popup({ + closeButton: false, closeOnClick: false, + maxWidth: '360px', className: 'maplibre-tooltip-popup', + }); + } + const container = document.createElement('div'); + container.className = 'maplibre-tooltip-popup__content'; + container.innerHTML = tooltipHtml; + tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map); + }; + + const onOut = () => { + clearTooltip(); + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + }; + + map.on('mousemove', onMove); + map.on('mouseout', onOut); + return () => { + map.off('mousemove', onMove); + map.off('mouseout', onOut); + clearTooltip(); + }; + }, [projection, activeTrack, clearTooltip]); + + /* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */ + const animRef = useRef(0); + + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay || projection !== 'mercator') { + cancelAnimationFrame(animRef.current); + return; + } + + const isTrackLayer = (id?: string) => + id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID; + + if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) { + cancelAnimationFrame(animRef.current); + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + if (filtered.length !== (existing as unknown[]).length) { + overlay.setProps({ layers: filtered } as never); + } + } catch { /* ignore */ } + return; + } + + // 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용 + const pathLayer = new PathLayer({ + id: DECK_PATH_ID, + data: [normalizedTrip], + getPath: (d) => d.path, + getColor: [...TRACK_COLOR, 90] as [number, number, number, number], + getWidth: 2, + widthMinPixels: 2, + widthUnits: 'pixels' as const, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + const sorted = [...activeTrack.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + const pointsLayer = new ScatterplotLayer({ + id: DECK_POINTS_ID, + data: sorted, + getPosition: (d) => [d.lon, d.lat], + getRadius: 4, + radiusUnits: 'pixels' as const, + getFillColor: TRACK_COLOR, + getLineColor: [0, 0, 0, 128], + lineWidthMinPixels: 1, + stroked: true, + pickable: true, + }); + + // rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음) + const { minTime, maxTime, durationSec } = timeRange; + const speed = durationSec / ANIM_CYCLE_SEC; + let current = minTime; + + const loop = () => { + current += speed / 60; + if (current > maxTime) current = minTime; + + const tripsLayer = new TripsLayer({ + id: DECK_TRIPS_ID, + data: [normalizedTrip], + getPath: (d: NormalizedTrip) => d.path, + getTimestamps: (d: NormalizedTrip) => d.timestamps, + getColor: (d: NormalizedTrip) => d.color, + currentTime: current, + trailLength: durationSec * 0.15, + fadeTrail: true, + widthMinPixels: 4, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never); + } catch { /* ignore */ } + + animRef.current = requestAnimationFrame(loop); + }; + + animRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(animRef.current); + }, [projection, normalizedTrip, activeTrack, timeRange]); + + /* ── 항적 조회 시 자동 fitBounds ── */ + useEffect(() => { + const map = mapRef.current; + if (!map || !activeTrack || activeTrack.points.length < 2) return; + if (projectionBusyRef.current) return; + + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const pt of activeTrack.points) { + if (pt.lon < minLon) minLon = pt.lon; + if (pt.lat < minLat) minLat = pt.lat; + if (pt.lon > maxLon) maxLon = pt.lon; + if (pt.lat > maxLat) maxLat = pt.lat; + } + + const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 }; + const apply = () => { + try { + map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts); + } catch { /* ignore */ } + }; + + if (map.isStyleLoaded()) { + apply(); + } else { + const onLoad = () => { apply(); map.off('styledata', onLoad); }; + map.on('styledata', onLoad); + return () => { map.off('styledata', onLoad); }; + } + }, [activeTrack]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index e1f19fd..645f2c1 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -1,4 +1,4 @@ -import { useEffect, type MutableRefObject } from 'react'; +import { useEffect, useMemo, type MutableRefObject } from 'react'; import maplibregl, { type GeoJSONSource, type GeoJSONSourceSpecification, @@ -10,6 +10,34 @@ import type { ZonesGeoJson } from '../../../entities/zone/api/useZones'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { BaseMapId, MapProjectionId } from '../types'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { guardedSetVisibility } from '../lib/layerHelpers'; + +/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임. + * 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로 + * ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */ +function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson { + const MAX_PTS = 60; + const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => { + if (ring.length <= MAX_PTS) return ring; + const step = Math.ceil(ring.length / MAX_PTS); + const out: GeoJSON.Position[] = [ring[0]]; + for (let i = step; i < ring.length - 1; i += step) out.push(ring[i]); + out.push(ring[0]); // close ring + return out; + }; + return { + ...zones, + features: zones.features.map((f) => ({ + ...f, + geometry: { + ...f.geometry, + coordinates: f.geometry.coordinates.map((polygon) => + polygon.map((ring) => subsample(ring)), + ), + }, + })), + }; +} export function useZonesLayer( mapRef: MutableRefObject, @@ -26,6 +54,12 @@ export function useZonesLayer( ) { const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts; + // globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지 + const simplifiedZones = useMemo( + () => (zones ? simplifyZonesForGlobe(zones) : null), + [zones], + ); + useEffect(() => { const map = mapRef.current; if (!map) return; @@ -47,33 +81,31 @@ export function useZonesLayer( zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']); const ensure = () => { - if (projectionBusyRef.current) return; - const visibility = overlays.zones ? 'visible' : 'none'; - try { - if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility); - } catch { - // ignore - } - try { - if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility); - } catch { - // ignore - } - try { - if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility); - } catch { - // ignore + // 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함 + // globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가 + // 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대 + const sourceData = projection === 'globe' ? simplifiedZones : zones; + if (sourceData) { + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(sourceData); + } catch { /* ignore — source may not exist yet */ } } + const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none'; + guardedSetVisibility(map, fillId, visibility); + guardedSetVisibility(map, lineId, visibility); + guardedSetVisibility(map, labelId, visibility); + + if (projectionBusyRef.current) return; if (!zones) return; if (!map.isStyleLoaded()) return; try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) { - existing.setData(zones); - } else { - map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification); + // 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨) + if (!map.getSource(srcId)) { + const data = projection === 'globe' ? simplifiedZones ?? zones : zones; + map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification); } const style = map.getStyle(); @@ -226,5 +258,5 @@ export function useZonesLayer( return () => { stop(); }; - }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); } diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 4c4089b..7d5a48a 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -11,10 +11,53 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c'; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ { id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, - { id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] }, - { id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] }, + { id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] }, + { id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] }, + { id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] }, + { id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] }, + { id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] }, + { id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] }, + { id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] }, ]; +/** + * 줌 기반 LOD — 줌아웃 시 vertex가 폭증하는 육지 레이어의 minzoom을 올려 + * 광역 뷰에서는 생략하고, 줌인 시 자연스럽게 디테일이 나타나도록 함. + * 해양 서비스 특성상 육지 디테일은 연안 확대 시에만 필요. + */ +function applyLandLayerLOD(style: StyleSpecification): void { + if (!style.layers || !Array.isArray(style.layers)) return; + + // source-layer → 렌더링을 시작할 최소 줌 레벨 + // globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지 + const LOD_MINZOOM: Record = { + 'landcover': 9, + 'globallandcover': 9, + 'landuse': 11, + 'boundary': 5, + 'transportation': 8, + 'transportation_name': 10, + 'building': 14, + 'housenumber': 16, + 'aeroway': 11, + 'park': 10, + 'mountain_peak': 11, + }; + + for (const layer of style.layers as unknown as LayerSpecification[]) { + const spec = layer as Record; + const sourceLayer = spec['source-layer'] as string | undefined; + if (!sourceLayer) continue; + const lodMin = LOD_MINZOOM[sourceLayer]; + if (lodMin === undefined) continue; + // 기존 minzoom보다 높을 때만 덮어씀 + const current = (spec.minzoom as number) ?? 0; + if (lodMin > current) { + spec.minzoom = lodMin; + } + } +} + export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { const oceanSourceId = 'maptiler-ocean'; @@ -31,17 +74,13 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const depth = ['to-number', ['get', 'depth']] as unknown as number[]; const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; - // Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean + // 수심 색상: 전체 범위 (-8000m ~ 0m) const bathyFillColor = [ 'interpolate', ['linear'], depth, - -11000, - '#00040b', -8000, '#010610', - -6000, - '#020816', -4000, '#030c1c', -2000, @@ -64,6 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK '#2097a6', ] as const; + // --- Depth tiers for zoom-based LOD --- + // 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일 + const DEPTHS_COARSE = [-1000, -2000]; + const DEPTHS_MEDIUM = [-100, -500, -1000, -2000, -4000]; + const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000, -4000, -8000]; + const depthIn = (depths: number[]) => + ['in', depth, ['literal', depths]] as unknown[]; + + // === Fill (contour polygons) — 단일 레이어, 전체 depth === const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', @@ -77,98 +125,132 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK }, } as unknown as LayerSpecification; - const bathyBandBorders: LayerSpecification = { + // === Borders (contour polygon edges) — 2-tier LOD === + // z3-z7: 1000m, 2000m 경계만 + const bathyBordersMajor: LayerSpecification = { + id: 'bathymetry-borders-major', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 3, + maxzoom: 7, + filter: depthIn(DEPTHS_COARSE) as unknown as unknown[], + paint: { + 'line-color': 'rgba(255,255,255,0.14)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35], + 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4], + }, + } as unknown as LayerSpecification; + + // z7+: 전체 등심선 경계 + const bathyBorders: LayerSpecification = { id: 'bathymetry-borders', type: 'line', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 5, // fill은 3부터, borders는 5부터 + minzoom: 7, maxzoom: 24, paint: { 'line-color': 'rgba(255,255,255,0.06)', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2], - 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6], }, } as unknown as LayerSpecification; - const bathyLinesMinor: LayerSpecification = { - id: 'bathymetry-lines', + // === Contour lines (contour_line) — 3-tier LOD === + // z5-z7: 1000m, 2000m만 + const bathyLinesCoarse: LayerSpecification = { + id: 'bathymetry-lines-coarse', type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', - minzoom: 7, + minzoom: 5, + maxzoom: 7, + filter: depthIn(DEPTHS_COARSE) as unknown as unknown[], paint: { - 'line-color': [ - 'interpolate', - ['linear'], - depth, - -11000, - 'rgba(255,255,255,0.04)', - -6000, - 'rgba(255,255,255,0.05)', - -2000, - 'rgba(255,255,255,0.07)', - 0, - 'rgba(255,255,255,0.10)', - ], - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3], - 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85], + 'line-color': 'rgba(255,255,255,0.12)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22], + 'line-blur': 0.5, + 'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6], }, } as unknown as LayerSpecification; - const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; - const bathyMajorDepthFilter: unknown[] = [ - 'in', - ['to-number', ['get', 'depth']], - ['literal', majorDepths], - ] as unknown[]; - + // z7-z9: 100, 500, 1000, 2000, 4000m const bathyLinesMajor: LayerSpecification = { id: 'bathymetry-lines-major', type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', minzoom: 7, - maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], + maxzoom: 9, + filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[], paint: { 'line-color': 'rgba(255,255,255,0.16)', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2], - 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95], }, } as unknown as LayerSpecification; - const bathyBandBordersMajor: LayerSpecification = { - id: 'bathymetry-borders-major', + // z9+: 50~8000m (풀 디테일) + const bathyLinesDetail: LayerSpecification = { + id: 'bathymetry-lines-detail', type: 'line', source: oceanSourceId, - 'source-layer': 'contour', - minzoom: 3, + 'source-layer': 'contour_line', + minzoom: 9, maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], + filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[], paint: { - 'line-color': 'rgba(255,255,255,0.14)', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15], - 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85], + 'line-color': 'rgba(255,255,255,0.16)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15], + 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3], }, } as unknown as LayerSpecification; + // === Labels — 2-tier LOD === + // z6-z9: 1000m, 2000m 라벨만 + const bathyLabelsCoarse: LayerSpecification = { + id: 'bathymetry-labels-coarse', + type: 'symbol', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 6, + maxzoom: 9, + filter: depthIn(DEPTHS_COARSE) as unknown as unknown[], + layout: { + 'symbol-placement': 'line', + 'text-field': depthLabel, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12], + 'text-allow-overlap': false, + 'text-padding': 4, + 'text-rotation-alignment': 'map', + }, + paint: { + 'text-color': 'rgba(226,232,240,0.78)', + 'text-halo-color': 'rgba(2,6,23,0.88)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.5, + }, + } as unknown as LayerSpecification; + + // z9+: 100~4000m 라벨 const bathyLabels: LayerSpecification = { id: 'bathymetry-labels', type: 'symbol', source: oceanSourceId, 'source-layer': 'contour_line', - minzoom: 7, - filter: bathyMajorDepthFilter as unknown as unknown[], + minzoom: 9, + filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[], layout: { 'symbol-placement': 'line', 'text-field': depthLabel, 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16], + 'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16], 'text-allow-overlap': false, 'text-padding': 4, 'text-rotation-alignment': 'map', @@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const toInsert = [ bathyFill, - bathyBandBorders, - bathyBandBordersMajor, - bathyLinesMinor, + bathyBordersMajor, + bathyBorders, + bathyLinesCoarse, bathyLinesMajor, + bathyLinesDetail, + bathyLabelsCoarse, bathyLabels, landformLabels, ].filter((l) => !existingIds.has(l.id)); @@ -273,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap // ignore } } + } function applyKoreanLabels(style: StyleSpecification) { @@ -298,6 +383,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise 1) { + ring[ring.length - 1] = ring[0]; } return ring; } diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 8a06564..ddf9a54 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -24,17 +24,10 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) { } } +// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피) const GLOBE_NATIVE_LAYER_IDS = [ - 'ships-globe-halo', - 'ships-globe-outline', - 'ships-globe', - 'ships-globe-label', - 'ships-globe-hover-halo', - 'ships-globe-hover-outline', - 'ships-globe-hover', 'pair-lines-ml', 'fc-lines-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', 'subcables-hitarea', @@ -43,19 +36,23 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', 'deck-globe', ]; const GLOBE_NATIVE_SOURCE_IDS = [ - 'ships-globe-src', - 'ships-globe-hover-src', 'pair-lines-ml-src', 'fc-lines-ml-src', 'fleet-circles-ml-src', - 'fleet-circles-ml-fill-src', 'pair-range-ml-src', 'subcables-src', 'subcables-pts-src', + 'vessel-track-line-src', + 'vessel-track-pts-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) { @@ -104,6 +101,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible } } +/** + * setLayoutProperty('visibility') wrapper — 현재 값과 동일하면 호출 생략. + * MapLibre는 setLayoutProperty 호출 시 항상 style._changed = true를 설정하여 + * 모든 symbol layer의 placement를 재계산시킴. text-allow-overlap:false 라벨이 + * 충돌 검사에 의해 사라지는 문제를 방지하기 위해, 값이 실제로 바뀔 때만 호출. + */ +export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') { + if (!map.getLayer(layerId)) return; + try { + if (map.getLayoutProperty(layerId, 'visibility') === target) return; + map.setLayoutProperty(layerId, 'visibility', target); + } catch { + // ignore + } +} + export function cleanupLayers( map: maplibregl.Map, layerIds: string[], 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/shipIconCache.ts b/apps/web/src/widgets/map3d/lib/shipIconCache.ts new file mode 100644 index 0000000..b7bdd8e --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/shipIconCache.ts @@ -0,0 +1,30 @@ +/** + * Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시. + * Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록 + * 인라인 data URL을 전달한다. + */ +const SHIP_SVG_URL = '/assets/ship.svg'; + +let _cachedDataUrl: string | null = null; +let _promise: Promise | null = null; + +function preloadShipIcon(): Promise { + if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl); + if (_promise) return _promise; + _promise = fetch(SHIP_SVG_URL) + .then((res) => res.text()) + .then((svg) => { + _cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; + return _cachedDataUrl; + }) + .catch(() => SHIP_SVG_URL); + return _promise; +} + +/** 캐시된 data URL 또는 폴백 URL 반환 */ +export function getCachedShipIcon(): string { + return _cachedDataUrl ?? SHIP_SVG_URL; +} + +// 모듈 임포트 시 즉시 로드 시작 +preloadShipIcon(); diff --git a/apps/web/src/widgets/map3d/lib/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/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index fb06a29..340b2f3 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -1,5 +1,6 @@ import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { fmtIsoFull } from '../../../shared/lib/datetime'; import { isFiniteNumber, toSafeNumber } from './setUtils'; export function formatNm(value: number | null | undefined) { @@ -54,7 +55,7 @@ export function getShipTooltipHtml({
${name}
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ''}
SOG: ${sog ?? '?'} kt · COG: ${cog ?? '?'}°
- ${msg ? `
${msg}
` : ''} + ${msg ? `
${fmtIsoFull(msg)}
` : ''} ${legacyHtml}
`, }; @@ -167,3 +168,54 @@ export function getFleetCircleTooltipHtml({
`, }; } + +function fmtMinutesKr(minutes: number): string { + if (minutes < 60) return `${minutes}분`; + if (minutes < 1440) return `${Math.round(minutes / 60)}시간`; + return `${Math.round(minutes / 1440)}일`; +} + +export function getTrackLineTooltipHtml({ + name, + pointCount, + minutes, + totalDistanceNm, +}: { + name: string; + pointCount: number; + minutes: number; + totalDistanceNm: number; +}) { + return { + html: `
+
항적 · ${name}
+
기간: ${fmtMinutesKr(minutes)} · 포인트: ${pointCount}
+
총 거리: ${totalDistanceNm.toFixed(1)} NM
+
`, + }; +} + +export function getTrackPointTooltipHtml({ + name, + sog, + cog, + heading, + status, + messageTimestamp, +}: { + name: string; + sog: number; + cog: number; + heading: number; + status: string; + messageTimestamp: string; +}) { + return { + html: `
+
${name}
+
SOG: ${isFiniteNumber(sog) ? sog : '?'} kt · COG: ${isFiniteNumber(cog) ? cog : '?'}°
+
Heading: ${isFiniteNumber(heading) ? heading : '?'}° · 상태: ${status || '-'}
+
${fmtIsoFull(messageTimestamp)}
+
`, + }; +} diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index a7be0ba..6a1d61d 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -1,6 +1,7 @@ import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; import type { SubcableGeoJson } from '../../entities/subcable/model/types'; +import type { ActiveTrack } from '../../entities/vesselTrack/model/types'; import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; @@ -15,6 +16,13 @@ export type Map3DSettings = { export type BaseMapId = 'enhanced' | 'legacy'; export type MapProjectionId = 'mercator' | 'globe'; +export interface MapViewState { + center: [number, number]; // [lon, lat] + zoom: number; + bearing: number; + pitch: number; +} + export interface Map3DProps { targets: AisTarget[]; zones: ZonesGeoJson | null; @@ -53,6 +61,14 @@ export interface Map3DProps { 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; + activeTrack?: ActiveTrack | null; + trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null; + onRequestTrack?: (mmsi: number, minutes: number) => void; + onCloseTrackMenu?: () => void; + onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; } export type DashSeg = { diff --git a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx index 9bfec56..07d831a 100644 --- a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx +++ b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx @@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) { const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))]; return ( -
+
-
+ {/* ── Header ── */} +
{color && (
)} -
{detail.name}
+
+ {detail.name} +
-
- Submarine Cable{detail.is_planned ? ' (Planned)' : ''} -
-
- -
- 길이 - {detail.length || '-'} -
-
- 개통 - {detail.rfs || '-'} -
- {detail.owners && ( -
- 운영사 - - {detail.owners} + {detail.is_planned && ( + + Planned -
- )} - {detail.suppliers && ( -
- 공급사 - {detail.suppliers} -
- )} + )} +
+ {/* ── Info rows ── */} +
+ + + {detail.owners && } + {detail.suppliers && } +
+ + {/* ── Landing Points ── */} {landingCount > 0 && ( -
-
- Landing Points ({landingCount}) · {countries.length} countries +
+
+ Landing Points + + {landingCount}곳 · {countries.length}개국 +
{detail.landing_points.map((lp) => ( -
- {lp.country}{' '} - {lp.name} - {lp.is_tbd && TBD} +
+ + {lp.country} + + {lp.name} + {lp.is_tbd && ( + + TBD + + )}
))}
)} + {/* ── Notes ── */} {detail.notes && ( -
+
{detail.notes}
)} + {/* ── Link ── */} {detail.url && ( - ); } + +function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) { + return ( +
+ {label} + + {value || '-'} + +
+ ); +} diff --git a/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/package-lock.json b/package-lock.json index adc6ffa..28a7738 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", @@ -383,6 +387,57 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.7.tgz", + "integrity": "sha512-jIsep2NByEimWlScqc/NLjpqWknLk5rd+uP8UAl7qI8CTInXV4KdzaYgujL+bE4lSV4Zlg0oMOAkbcviMKDLNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.7.tgz", + "integrity": "sha512-DiEfsmWrW0EIM44FdwzdPAjUr8a8IPelpkt2fPvrgmaS0OSZFB1PkJWLmM3hoYO8mH0vuD0YL//m+Pvtw3bGSw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "^4.3.4", + "@loaders.gl/gis": "^4.3.4", + "@loaders.gl/loader-utils": "^4.3.4", + "@loaders.gl/mvt": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@loaders.gl/terrain": "^4.3.4", + "@loaders.gl/tiles": "^4.3.4", + "@loaders.gl/wms": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "^4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/layers": { "version": "9.2.7", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.7.tgz", @@ -422,6 +477,26 @@ "@math.gl/web-mercator": "^4.1.0" } }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.7.tgz", + "integrity": "sha512-EpWHJ3GaCXELCsYRlabvkXxtgLQwOZYU8YPOmlKUYf+/410B2D89oNGtJinRcfM1/T9TBelBS9CHMYsL1tv9cA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/gltf": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1276,6 +1351,73 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/compression/node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, + "node_modules/@loaders.gl/compression/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@loaders.gl/core": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", @@ -1289,6 +1431,96 @@ "@probe.gl/log": "^4.0.2" } }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/images": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", @@ -1316,6 +1548,51 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", @@ -1328,6 +1605,74 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", @@ -1337,11 +1682,42 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@luma.gl/core": { "version": "9.2.6", @@ -1374,6 +1750,24 @@ "@luma.gl/shadertools": "~9.2.0" } }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, "node_modules/@luma.gl/shadertools": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", @@ -1424,6 +1818,12 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1629,6 +2029,26 @@ "@math.gl/types": "4.1.0" } }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, "node_modules/@math.gl/polygon": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", @@ -2059,6 +2479,68 @@ "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", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2104,6 +2586,21 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2137,7 +2634,6 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2149,6 +2645,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, "node_modules/@types/rbush": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", @@ -2159,7 +2661,7 @@ "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": { @@ -2494,6 +2996,15 @@ "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", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2649,6 +3160,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2670,6 +3202,16 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2705,6 +3247,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2767,6 +3318,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -2824,6 +3384,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2852,11 +3431,20 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d3-hexbin": { @@ -2890,6 +3478,18 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2899,6 +3499,12 @@ "node": ">=6" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -3268,6 +3874,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", @@ -3556,6 +4180,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3583,6 +4218,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3593,6 +4248,24 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3620,6 +4293,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -3629,6 +4308,18 @@ "node": ">= 10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3652,6 +4343,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3757,6 +4454,24 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -3773,6 +4488,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, "node_modules/lerc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", @@ -3793,6 +4514,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -3853,6 +4583,15 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3863,6 +4602,19 @@ "yallist": "^3.0.2" } }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, "node_modules/maplibre-gl": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", @@ -3906,6 +4658,17 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4232,6 +4995,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4352,6 +5121,21 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -4481,6 +5265,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -4547,6 +5337,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4570,6 +5366,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -4598,6 +5400,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4611,6 +5419,15 @@ "node": ">=0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4624,6 +5441,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -4646,6 +5475,28 @@ "node": ">=8" } }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/texture-compressor/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -4785,7 +5636,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unzipit": { @@ -4841,6 +5691,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -5076,11 +5932,47 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true + }, "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 + } + } } } } From 86a0b2276f9a25fdc8f47cb597a719fcbeb312b8 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 22:43:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20vessel-track=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결 - track DTO 좌표/시간 정규화 + stale query 응답 무시 - 조회 직후 표시 안정화 및 기본 100x 자동재생 적용 - Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원 - liveRenderer batch rendering + trackReplay store 기반 구조 반영 Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 47 +++--- apps/web/src/widgets/map3d/Map3D.tsx | 159 +++++++++++++----- .../widgets/map3d/hooks/useGlobeOverlays.ts | 94 ++++++++++- .../src/widgets/map3d/hooks/useGlobeShips.ts | 105 +++++++++++- .../map3d/hooks/useProjectionToggle.ts | 6 + .../map3d/hooks/useVesselTrackLayer.ts | 1 + 6 files changed, 335 insertions(+), 77 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index c746e4f..734ae11 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -27,17 +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 { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; -import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; -import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; -import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; 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, @@ -82,6 +83,7 @@ export function DashboardPage() { const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); + const weather = useWeatherPolling(zones); const [mapInstance, setMapInstance] = useState(null); const weatherOverlay = useWeatherOverlay(mapInstance); @@ -142,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, @@ -762,16 +770,16 @@ export function DashboardPage() { onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} - onMapReady={handleMapReady} initialView={mapView} onViewStateChange={setMapView} - activeTrack={activeTrack} + activeTrack={null} trackContextMenu={trackContextMenu} onRequestTrack={handleRequestTrack} onCloseTrackMenu={handleCloseTrackMenu} onOpenTrackMenu={handleOpenTrackMenu} + onMapReady={handleMapReady} /> - + + {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 5fd922b..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'; @@ -68,7 +72,6 @@ export function Map3D({ onHoverCable, onClickCable, mapStyleSettings, - onMapReady, initialView, onViewStateChange, onGlobeShipsReady, @@ -77,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); @@ -201,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); @@ -236,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 => { @@ -295,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; @@ -336,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( @@ -364,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 @@ -413,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( @@ -480,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, }, @@ -509,7 +588,7 @@ export function Map3D({ useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { - projection, settings, trackReplayDeckLayers: [], 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, @@ -536,9 +615,9 @@ export function Map3D({ }, ); - useVesselTrackLayer( - mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers, - { activeTrack, projection, mapSyncEpoch }, + useTrackReplayLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState }, ); // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 @@ -561,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[] = []; 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/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/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,