Compare commits
No commits in common. "4d1a8a0d1e1ee080176c65603d19ec0694f073e9" and "c05ec159ce80e2e6cf2f2bbec76dc35cb68fb602" have entirely different histories.
4d1a8a0d1e
...
c05ec159ce
@ -15,15 +15,11 @@
|
|||||||
"@deck.gl/geo-layers": "^9.2.7",
|
"@deck.gl/geo-layers": "^9.2.7",
|
||||||
"@deck.gl/layers": "^9.2.7",
|
"@deck.gl/layers": "^9.2.7",
|
||||||
"@deck.gl/mapbox": "^9.2.7",
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
"@maptiler/weather": "^3.1.1",
|
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1259,549 +1259,6 @@ body {
|
|||||||
border-color: var(--accent);
|
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) {
|
@media (max-width: 920px) {
|
||||||
.app {
|
.app {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -1,107 +0,0 @@
|
|||||||
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<WeatherSnapshot> {
|
|
||||||
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<MarineResponse>),
|
|
||||||
fetch(`${WEATHER_BASE}?${coords}&${WEATHER_PARAMS}`, { signal: ac.signal })
|
|
||||||
.then((r) => r.json() as Promise<WeatherResponse>),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 단일 좌표면 배열이 아닌 단일 객체가 반환됨 → 통일
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
/** 기상 데이터 유틸리티 */
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
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<number, number> = {
|
|
||||||
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<string, number> = {
|
|
||||||
'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<string, number>();
|
|
||||||
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<typeof setTimeout> | 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<typeof setTimeout>);
|
|
||||||
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<typeof setTimeout>);
|
|
||||||
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;
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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<number, LegacyVesselInfo> | null,
|
|
||||||
) {
|
|
||||||
return useMemo(() => toLiveShipFeatures(targets, legacyHits), [targets, legacyHits]);
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<maplibregl.Map | null>,
|
|
||||||
features: LiveShipFeature[],
|
|
||||||
sourceTargets: AisTarget[],
|
|
||||||
mapSyncEpoch: number,
|
|
||||||
): UseLiveShipBatchRenderResult {
|
|
||||||
const rendererRef = useRef<ShipBatchRenderer | null>(null);
|
|
||||||
const [renderedFeatures, setRenderedFeatures] = useState<LiveShipFeature[]>([]);
|
|
||||||
|
|
||||||
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<number>();
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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<number, LegacyVesselInfo> | 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;
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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<ReplayTrip>({
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
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<PathData>({
|
|
||||||
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<PathData>) => {
|
|
||||||
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<PointData>({
|
|
||||||
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<CurrentVesselPosition>({
|
|
||||||
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<CurrentVesselPosition>) => {
|
|
||||||
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<CurrentVesselPosition>({
|
|
||||||
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-');
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
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<typeof entry> => 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<string, [number, number, number, number]> = {
|
|
||||||
'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'];
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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 }>;
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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<boolean> {
|
|
||||||
void handlers;
|
|
||||||
if (!this.enabled) return false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async startQuery(request: ReplayStreamQueryRequest): Promise<boolean> {
|
|
||||||
void request;
|
|
||||||
if (!this.enabled) return false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancel(): Promise<void> {
|
|
||||||
if (!this.enabled) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
if (!this.enabled) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const replayStreamService = new ReplayStreamService();
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
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<string | number>;
|
|
||||||
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<ProcessedTrack[]> {
|
|
||||||
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<ProcessedTrack[]> {
|
|
||||||
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<ProcessedTrack[]> {
|
|
||||||
const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase();
|
|
||||||
|
|
||||||
if (mode === 'v2') {
|
|
||||||
return queryV2Track(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryLegacyTrack(params);
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
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<TrackPlaybackState>()((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;
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
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<string>;
|
|
||||||
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<TrackQueryState>()((set, get) => ({
|
|
||||||
tracks: [],
|
|
||||||
disabledVesselIds: new Set<string>(),
|
|
||||||
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<string>(),
|
|
||||||
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<string>(),
|
|
||||||
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<string>(),
|
|
||||||
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<string>(),
|
|
||||||
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<string>(),
|
|
||||||
highlightedVesselId: null,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
queryState: 'idle',
|
|
||||||
renderEpoch: state.renderEpoch + 1,
|
|
||||||
lastQueryKey: null,
|
|
||||||
showPoints: true,
|
|
||||||
showVirtualShip: true,
|
|
||||||
showLabels: true,
|
|
||||||
showTrail: true,
|
|
||||||
hideLiveShips: false,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -1,415 +0,0 @@
|
|||||||
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<WeatherLayerId, boolean> = {
|
|
||||||
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<WeatherLayerId, LegendInfo> = {
|
|
||||||
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<WeatherLayerId, boolean>;
|
|
||||||
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<Record<WeatherLayerId, boolean>>({ ...DEFAULT_ENABLED });
|
|
||||||
|
|
||||||
const [opacity, setOpacityState] = useState(0.6);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [animationSpeed, setAnimationSpeed] = useState(3600);
|
|
||||||
const [currentTime, setCurrentTime] = useState<Date | null>(null);
|
|
||||||
const [startTime, setStartTime] = useState<Date | null>(null);
|
|
||||||
const [endTime, setEndTime] = useState<Date | null>(null);
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
|
||||||
const [steps, setSteps] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const layerInstancesRef = useRef<Map<WeatherLayerId, AnyWeatherLayer>>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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<WeatherSnapshot | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const abortRef = useRef<AbortController | null>(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 };
|
|
||||||
}
|
|
||||||
@ -27,18 +27,13 @@ import { Topbar } from "../../widgets/topbar/Topbar";
|
|||||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||||
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
||||||
import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime";
|
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 {
|
import {
|
||||||
buildLegacyHitMap,
|
buildLegacyHitMap,
|
||||||
computeCountsByType,
|
computeCountsByType,
|
||||||
@ -84,13 +79,6 @@ export function DashboardPage() {
|
|||||||
const { data: subcableData } = useSubcables();
|
const { data: subcableData } = useSubcables();
|
||||||
const legacyIndex = useLegacyIndex(legacyData);
|
const legacyIndex = useLegacyIndex(legacyData);
|
||||||
|
|
||||||
const weather = useWeatherPolling(zones);
|
|
||||||
const [mapInstance, setMapInstance] = useState<import("maplibre-gl").Map | null>(null);
|
|
||||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
|
||||||
const handleMapReady = useCallback((map: import("maplibre-gl").Map) => {
|
|
||||||
setMapInstance(map);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||||
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||||
const [useApiBbox, setUseApiBbox] = useState(false);
|
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||||
@ -144,33 +132,27 @@ export function DashboardPage() {
|
|||||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 항적 (vessel track)
|
// 항적 (vessel track)
|
||||||
|
const [activeTrack, setActiveTrack] = useState<ActiveTrack | null>(null);
|
||||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(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 }) => {
|
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
|
||||||
setTrackContextMenu(info);
|
setTrackContextMenu(info);
|
||||||
}, []);
|
}, []);
|
||||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||||
const trackStore = useTrackQueryStore.getState();
|
|
||||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
|
||||||
trackStore.beginQuery(queryKey);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = targets.find((item) => item.mmsi === mmsi);
|
const res = await fetchVesselTrack(mmsi, minutes);
|
||||||
const tracks = await queryTrackByMmsi({
|
if (res.success && res.data.length > 0) {
|
||||||
mmsi,
|
const sorted = [...res.data].sort(
|
||||||
minutes,
|
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||||
shipNameHint: target?.name,
|
);
|
||||||
});
|
setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() });
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
trackStore.applyTracksSuccess(tracks, queryKey);
|
|
||||||
} else {
|
} else {
|
||||||
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
|
console.warn('Track: no data', res.message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
console.warn('Track fetch failed:', e);
|
||||||
}
|
}
|
||||||
}, [targets]);
|
}, []);
|
||||||
|
|
||||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||||
showShips: true, showDensity: false, showSeamark: false,
|
showShips: true, showDensity: false, showSeamark: false,
|
||||||
@ -772,21 +754,12 @@ export function DashboardPage() {
|
|||||||
mapStyleSettings={mapStyleSettings}
|
mapStyleSettings={mapStyleSettings}
|
||||||
initialView={mapView}
|
initialView={mapView}
|
||||||
onViewStateChange={setMapView}
|
onViewStateChange={setMapView}
|
||||||
activeTrack={null}
|
activeTrack={activeTrack}
|
||||||
trackContextMenu={trackContextMenu}
|
trackContextMenu={trackContextMenu}
|
||||||
onRequestTrack={handleRequestTrack}
|
onRequestTrack={handleRequestTrack}
|
||||||
onCloseTrackMenu={handleCloseTrackMenu}
|
onCloseTrackMenu={handleCloseTrackMenu}
|
||||||
onOpenTrackMenu={handleOpenTrackMenu}
|
onOpenTrackMenu={handleOpenTrackMenu}
|
||||||
onMapReady={handleMapReady}
|
|
||||||
/>
|
/>
|
||||||
<GlobalTrackReplayPanel />
|
|
||||||
<WeatherPanel
|
|
||||||
snapshot={weather.snapshot}
|
|
||||||
isLoading={weather.isLoading}
|
|
||||||
error={weather.error}
|
|
||||||
onRefresh={weather.refresh}
|
|
||||||
/>
|
|
||||||
<WeatherOverlayPanel {...weatherOverlay} />
|
|
||||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
|
|||||||
@ -26,13 +26,9 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
|||||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||||
import { useDeckLayers } from './hooks/useDeckLayers';
|
import { useDeckLayers } from './hooks/useDeckLayers';
|
||||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||||
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
|
import { useVesselTrackLayer } from './hooks/useVesselTrackLayer';
|
||||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||||
import { VesselContextMenu } from './components/VesselContextMenu';
|
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';
|
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
||||||
|
|
||||||
@ -80,8 +76,14 @@ export function Map3D({
|
|||||||
onRequestTrack,
|
onRequestTrack,
|
||||||
onCloseTrackMenu,
|
onCloseTrackMenu,
|
||||||
onOpenTrackMenu,
|
onOpenTrackMenu,
|
||||||
onMapReady,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
void onHoverFleet;
|
||||||
|
void onClearFleetHover;
|
||||||
|
void onHoverMmsi;
|
||||||
|
void onClearMmsiHover;
|
||||||
|
void onHoverPair;
|
||||||
|
void onClearPairHover;
|
||||||
|
|
||||||
// ── Shared refs ──────────────────────────────────────────────────────
|
// ── Shared refs ──────────────────────────────────────────────────────
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
@ -198,38 +200,20 @@ export function Map3D({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Ship data memos ──────────────────────────────────────────────────
|
// ── Ship data memos ──────────────────────────────────────────────────
|
||||||
const rawShipData = useMemo(() => {
|
const shipData = useMemo(() => {
|
||||||
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
|
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
|
||||||
}, [targets]);
|
}, [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 shipByMmsi = useMemo(() => {
|
||||||
const byMmsi = new Map<number, AisTarget>();
|
const byMmsi = new Map<number, AisTarget>();
|
||||||
for (const t of rawShipData) byMmsi.set(t.mmsi, t);
|
for (const t of shipData) byMmsi.set(t.mmsi, t);
|
||||||
return byMmsi;
|
return byMmsi;
|
||||||
}, [rawShipData]);
|
}, [shipData]);
|
||||||
|
|
||||||
const shipLayerData = useMemo(() => {
|
const shipLayerData = useMemo(() => {
|
||||||
if (hideLiveShips) return [];
|
if (shipData.length === 0) return shipData;
|
||||||
// Fallback to raw targets when batch result is temporarily empty
|
return [...shipData];
|
||||||
// (e.g. overlay update race or viewport sync delay).
|
}, [shipData]);
|
||||||
if (batchRenderedTargets.length === 0) return rawShipData;
|
|
||||||
return [...batchRenderedTargets];
|
|
||||||
}, [hideLiveShips, batchRenderedTargets, rawShipData]);
|
|
||||||
|
|
||||||
const shipHighlightSet = useMemo(() => {
|
const shipHighlightSet = useMemo(() => {
|
||||||
const out = new Set(highlightedMmsiSetForShips);
|
const out = new Set(highlightedMmsiSetForShips);
|
||||||
@ -251,8 +235,6 @@ export function Map3D({
|
|||||||
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
|
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
}, [shipHighlightSet, shipLayerData]);
|
}, [shipHighlightSet, shipLayerData]);
|
||||||
|
|
||||||
const trackReplayRenderState = useTrackReplayDeckLayers();
|
|
||||||
|
|
||||||
// ── Deck hover management ────────────────────────────────────────────
|
// ── Deck hover management ────────────────────────────────────────────
|
||||||
const hasAuxiliarySelectModifier = useCallback(
|
const hasAuxiliarySelectModifier = useCallback(
|
||||||
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
|
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
|
||||||
@ -312,51 +294,22 @@ export function Map3D({
|
|||||||
ownerKey: null,
|
ownerKey: null,
|
||||||
vesselMmsis: [],
|
vesselMmsis: [],
|
||||||
});
|
});
|
||||||
const mapDrivenMmsiHoverRef = useRef(false);
|
|
||||||
const mapDrivenPairHoverRef = useRef(false);
|
|
||||||
const mapDrivenFleetHoverRef = useRef(false);
|
|
||||||
|
|
||||||
const clearMapFleetHoverState = useCallback(() => {
|
const clearMapFleetHoverState = useCallback(() => {
|
||||||
const prev = mapFleetHoverStateRef.current;
|
|
||||||
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
|
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
|
||||||
setHoveredDeckFleetOwner(null);
|
setHoveredDeckFleetOwner(null);
|
||||||
setHoveredDeckFleetMmsis([]);
|
setHoveredDeckFleetMmsis([]);
|
||||||
if (
|
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]);
|
||||||
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 clearDeckHoverPairs = useCallback(() => {
|
||||||
const prev = mapDeckPairHoverRef.current;
|
|
||||||
mapDeckPairHoverRef.current = [];
|
mapDeckPairHoverRef.current = [];
|
||||||
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
||||||
if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) {
|
}, [setHoveredDeckPairMmsiSet]);
|
||||||
onClearPairHover?.();
|
|
||||||
}
|
|
||||||
mapDrivenPairHoverRef.current = false;
|
|
||||||
}, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]);
|
|
||||||
|
|
||||||
const clearDeckHoverMmsi = useCallback(() => {
|
const clearDeckHoverMmsi = useCallback(() => {
|
||||||
const prev = mapDeckMmsiHoverRef.current;
|
|
||||||
mapDeckMmsiHoverRef.current = [];
|
mapDeckMmsiHoverRef.current = [];
|
||||||
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
||||||
if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) {
|
}, [setHoveredDeckMmsiSet]);
|
||||||
onClearMmsiHover?.();
|
|
||||||
}
|
|
||||||
mapDrivenMmsiHoverRef.current = false;
|
|
||||||
}, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]);
|
|
||||||
|
|
||||||
const scheduleDeckHoverResolve = useCallback(() => {
|
const scheduleDeckHoverResolve = useCallback(() => {
|
||||||
if (deckHoverRafRef.current != null) return;
|
if (deckHoverRafRef.current != null) return;
|
||||||
@ -382,41 +335,21 @@ export function Map3D({
|
|||||||
const setDeckHoverMmsi = useCallback(
|
const setDeckHoverMmsi = useCallback(
|
||||||
(next: number[]) => {
|
(next: number[]) => {
|
||||||
const normalized = makeUniqueSorted(next);
|
const normalized = makeUniqueSorted(next);
|
||||||
const prev = mapDeckMmsiHoverRef.current;
|
|
||||||
touchDeckHoverState(normalized.length > 0);
|
touchDeckHoverState(normalized.length > 0);
|
||||||
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
||||||
mapDeckMmsiHoverRef.current = 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, onHoverMmsi, onClearMmsiHover],
|
[setHoveredDeckMmsiSet, touchDeckHoverState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setDeckHoverPairs = useCallback(
|
const setDeckHoverPairs = useCallback(
|
||||||
(next: number[]) => {
|
(next: number[]) => {
|
||||||
const normalized = makeUniqueSorted(next);
|
const normalized = makeUniqueSorted(next);
|
||||||
const prev = mapDeckPairHoverRef.current;
|
|
||||||
touchDeckHoverState(normalized.length > 0);
|
touchDeckHoverState(normalized.length > 0);
|
||||||
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
||||||
mapDeckPairHoverRef.current = 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, onHoverPair, onClearPairHover],
|
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setMapFleetHoverState = useCallback(
|
const setMapFleetHoverState = useCallback(
|
||||||
@ -430,21 +363,8 @@ export function Map3D({
|
|||||||
setHoveredDeckFleetOwner(ownerKey);
|
setHoveredDeckFleetOwner(ownerKey);
|
||||||
setHoveredDeckFleetMmsis(normalized);
|
setHoveredDeckFleetMmsis(normalized);
|
||||||
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: 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
|
// hover RAF cleanup
|
||||||
@ -492,37 +412,37 @@ export function Map3D({
|
|||||||
}, [pairLinks]);
|
}, [pairLinks]);
|
||||||
|
|
||||||
const pairLinksInteractive = useMemo(() => {
|
const pairLinksInteractive = useMemo(() => {
|
||||||
if ((pairLinks?.length ?? 0) === 0) return [];
|
if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return [];
|
||||||
if (effectiveHoveredPairMmsiSet.size < 2) return [];
|
if (hoveredPairMmsiSetRef.size < 2) return [];
|
||||||
const links = pairLinks || [];
|
const links = pairLinks || [];
|
||||||
return links.filter((link) =>
|
return links.filter((link) =>
|
||||||
effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi),
|
hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi),
|
||||||
);
|
);
|
||||||
}, [pairLinks, effectiveHoveredPairMmsiSet]);
|
}, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]);
|
||||||
|
|
||||||
const pairRangesInteractive = useMemo(() => {
|
const pairRangesInteractive = useMemo(() => {
|
||||||
if (pairRanges.length === 0) return [];
|
if (!overlays.pairRange || pairRanges.length === 0) return [];
|
||||||
if (effectiveHoveredPairMmsiSet.size < 2) return [];
|
if (hoveredPairMmsiSetRef.size < 2) return [];
|
||||||
return pairRanges.filter((range) =>
|
return pairRanges.filter((range) =>
|
||||||
effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi),
|
hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi),
|
||||||
);
|
);
|
||||||
}, [pairRanges, effectiveHoveredPairMmsiSet]);
|
}, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]);
|
||||||
|
|
||||||
const fcLinesInteractive = useMemo(() => {
|
const fcLinesInteractive = useMemo(() => {
|
||||||
if (fcDashed.length === 0) return [];
|
if (!overlays.fcLines || fcDashed.length === 0) return [];
|
||||||
if (highlightedMmsiSetCombined.size === 0) return [];
|
if (highlightedMmsiSetCombined.size === 0) return [];
|
||||||
return fcDashed.filter(
|
return fcDashed.filter(
|
||||||
(line) =>
|
(line) =>
|
||||||
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
|
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
|
||||||
);
|
);
|
||||||
}, [fcDashed, highlightedMmsiSetCombined]);
|
}, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]);
|
||||||
|
|
||||||
const fleetCirclesInteractive = useMemo(() => {
|
const fleetCirclesInteractive = useMemo(() => {
|
||||||
if ((fleetCircles?.length ?? 0) === 0) return [];
|
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return [];
|
||||||
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
|
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
|
||||||
const circles = fleetCircles || [];
|
const circles = fleetCircles || [];
|
||||||
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
|
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
|
||||||
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]);
|
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
|
||||||
|
|
||||||
// ── Hook orchestration ───────────────────────────────────────────────
|
// ── Hook orchestration ───────────────────────────────────────────────
|
||||||
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
|
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
|
||||||
@ -559,9 +479,9 @@ export function Map3D({
|
|||||||
useGlobeShips(
|
useGlobeShips(
|
||||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||||
{
|
{
|
||||||
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
||||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady,
|
||||||
},
|
},
|
||||||
@ -588,7 +508,7 @@ export function Map3D({
|
|||||||
useDeckLayers(
|
useDeckLayers(
|
||||||
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
||||||
{
|
{
|
||||||
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -615,9 +535,9 @@ export function Map3D({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useTrackReplayLayer(
|
useVesselTrackLayer(
|
||||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||||
{ activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState },
|
{ activeTrack, projection, mapSyncEpoch },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
|
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
|
||||||
@ -640,7 +560,7 @@ export function Map3D({
|
|||||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
||||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
const point: [number, number] = [e.offsetX, e.offsetY];
|
||||||
const shipLayerIds = [
|
const shipLayerIds = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
].filter((id) => map.getLayer(id));
|
].filter((id) => map.getLayer(id));
|
||||||
|
|
||||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||||
@ -676,14 +596,6 @@ export function Map3D({
|
|||||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
|
|||||||
@ -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,
|
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] = [
|
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], 0,
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Highlighted variants ──
|
// ── 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,
|
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] = [
|
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], 120,
|
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── MapLibre overlay colors ──
|
// ── 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_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 FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
|
||||||
|
|
||||||
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.12);
|
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
|
||||||
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.42);
|
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
|
||||||
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
|
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
|
||||||
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,6 @@ export function useDeckLayers(
|
|||||||
opts: {
|
opts: {
|
||||||
projection: MapProjectionId;
|
projection: MapProjectionId;
|
||||||
settings: Map3DSettings;
|
settings: Map3DSettings;
|
||||||
trackReplayDeckLayers: unknown[];
|
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
shipOverlayLayerData: AisTarget[];
|
shipOverlayLayerData: AisTarget[];
|
||||||
shipData: AisTarget[];
|
shipData: AisTarget[];
|
||||||
@ -104,7 +103,7 @@ export function useDeckLayers(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -466,16 +465,16 @@ export function useDeckLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pairRangesInteractive.length > 0) {
|
if (overlays.pairRange && pairRangesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
if (pairLinksInteractive.length > 0) {
|
if (overlays.pairLines && pairLinksInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (fcLinesInteractive.length > 0) {
|
if (overlays.fcLines && fcLinesInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (fleetCirclesInteractive.length > 0) {
|
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
@ -489,9 +488,7 @@ export function useDeckLayers(
|
|||||||
layers.push(new IconLayer<AisTarget>({ 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); } }));
|
layers.push(new IconLayer<AisTarget>({ 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 normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
|
||||||
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
|
||||||
const deckProps = {
|
const deckProps = {
|
||||||
layers: normalizedLayers,
|
layers: normalizedLayers,
|
||||||
getTooltip: (info: PickingInfo) => {
|
getTooltip: (info: PickingInfo) => {
|
||||||
@ -556,7 +553,12 @@ export function useDeckLayers(
|
|||||||
try {
|
try {
|
||||||
deckTarget.setProps(deckProps as never);
|
deckTarget.setProps(deckProps as never);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
|
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
|
||||||
|
try {
|
||||||
|
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
|
||||||
|
} catch {
|
||||||
|
// Ignore secondary failure.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
ensureMercatorOverlay,
|
ensureMercatorOverlay,
|
||||||
@ -581,7 +583,6 @@ export function useDeckLayers(
|
|||||||
overlays.fleetCircles,
|
overlays.fleetCircles,
|
||||||
settings.showDensity,
|
settings.showDensity,
|
||||||
settings.showShips,
|
settings.showShips,
|
||||||
trackReplayDeckLayers,
|
|
||||||
onDeckSelectOrHighlight,
|
onDeckSelectOrHighlight,
|
||||||
onSelectMmsi,
|
onSelectMmsi,
|
||||||
onToggleHighlightMmsi,
|
onToggleHighlightMmsi,
|
||||||
|
|||||||
@ -184,7 +184,7 @@ export function useGlobeInteraction(
|
|||||||
let candidateLayerIds: string[] = [];
|
let candidateLayerIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
candidateLayerIds = [
|
candidateLayerIds = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
'pair-lines-ml', 'fc-lines-ml',
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
@ -211,7 +211,7 @@ export function useGlobeInteraction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const priority = [
|
const priority = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'zones-fill', 'zones-line', 'zones-label',
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
@ -229,11 +229,7 @@ export function useGlobeInteraction(
|
|||||||
|
|
||||||
const layerId = first.layer?.id;
|
const layerId = first.layer?.id;
|
||||||
const props = first.properties || {};
|
const props = first.properties || {};
|
||||||
const isShipLayer =
|
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||||
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 isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||||
const isFcLayer = layerId === 'fc-lines-ml';
|
const isFcLayer = layerId === 'fc-lines-ml';
|
||||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||||
FLEET_FILL_ML_HL,
|
|
||||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { makeUniqueSorted } from '../lib/setUtils';
|
import { makeUniqueSorted } from '../lib/setUtils';
|
||||||
@ -67,8 +66,7 @@ export function useGlobeOverlays(
|
|||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) {
|
||||||
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
|
||||||
remove();
|
remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -142,7 +140,7 @@ export function useGlobeOverlays(
|
|||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
// FC lines
|
// FC lines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -159,9 +157,7 @@ export function useGlobeOverlays(
|
|||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
if (projection !== 'globe' || !overlays.fcLines) {
|
||||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
|
||||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
|
||||||
remove();
|
remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -239,15 +235,7 @@ export function useGlobeOverlays(
|
|||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
projection,
|
|
||||||
overlays.fcLines,
|
|
||||||
fcLinks,
|
|
||||||
hoveredPairMmsiList,
|
|
||||||
hoveredFleetMmsiList,
|
|
||||||
mapSyncEpoch,
|
|
||||||
reorderGlobeFeatureLayers,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fleet circles
|
// Fleet circles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -255,35 +243,26 @@ export function useGlobeOverlays(
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const srcId = 'fleet-circles-ml-src';
|
const srcId = 'fleet-circles-ml-src';
|
||||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
|
||||||
const layerId = 'fleet-circles-ml';
|
const layerId = 'fleet-circles-ml';
|
||||||
const fillLayerId = 'fleet-circles-ml-fill';
|
|
||||||
|
|
||||||
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||||
// 라인만으로 fleet circle 시각화 충분
|
// 라인만으로 fleet circle 시각화 충분
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
guardedSetVisibility(map, layerId, 'none');
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
guardedSetVisibility(map, fillLayerId, 'none');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
|
||||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
|
||||||
remove();
|
remove();
|
||||||
return;
|
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<GeoJSON.LineString> = {
|
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: circles.map((c) => {
|
features: (fleetCircles || []).map((c) => {
|
||||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
@ -301,23 +280,6 @@ export function useGlobeOverlays(
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
|
||||||
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 {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fcLine);
|
if (existing) existing.setData(fcLine);
|
||||||
@ -327,14 +289,6 @@ export function useGlobeOverlays(
|
|||||||
return;
|
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)) {
|
if (!map.getLayer(layerId)) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
@ -358,27 +312,6 @@ export function useGlobeOverlays(
|
|||||||
guardedSetVisibility(map, layerId, 'visible');
|
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();
|
reorderGlobeFeatureLayers();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
@ -388,15 +321,7 @@ export function useGlobeOverlays(
|
|||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
projection,
|
|
||||||
overlays.fleetCircles,
|
|
||||||
fleetCircles,
|
|
||||||
hoveredFleetOwnerKeyList,
|
|
||||||
hoveredFleetMmsiList,
|
|
||||||
mapSyncEpoch,
|
|
||||||
reorderGlobeFeatureLayers,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Pair range
|
// Pair range
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -413,8 +338,7 @@ export function useGlobeOverlays(
|
|||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
if (projection !== 'globe' || !overlays.pairRange) {
|
||||||
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
|
||||||
remove();
|
remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -503,7 +427,7 @@ export function useGlobeOverlays(
|
|||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
// Paint state updates for hover highlights
|
// Paint state updates for hover highlights
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
|||||||
@ -270,14 +270,13 @@ export function useGlobeShips(
|
|||||||
const srcId = 'ships-globe-src';
|
const srcId = 'ships-globe-src';
|
||||||
const haloId = 'ships-globe-halo';
|
const haloId = 'ships-globe-halo';
|
||||||
const outlineId = 'ships-globe-outline';
|
const outlineId = 'ships-globe-outline';
|
||||||
const symbolLiteId = 'ships-globe-lite';
|
|
||||||
const symbolId = 'ships-globe';
|
const symbolId = 'ships-globe';
|
||||||
const labelId = 'ships-globe-label';
|
const labelId = 'ships-globe-label';
|
||||||
|
|
||||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
|
for (const id of [labelId, symbolId, outlineId, haloId]) {
|
||||||
guardedSetVisibility(map, id, 'none');
|
guardedSetVisibility(map, id, 'none');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -301,12 +300,10 @@ export function useGlobeShips(
|
|||||||
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
||||||
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
||||||
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
||||||
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
|
if (map.getLayer(symbolId)) {
|
||||||
const changed =
|
const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility;
|
||||||
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
|
||||||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
|
for (const id of [haloId, outlineId, symbolId]) {
|
||||||
guardedSetVisibility(map, id, visibility);
|
guardedSetVisibility(map, id, visibility);
|
||||||
}
|
}
|
||||||
if (projection === 'globe') kickRepaint(map);
|
if (projection === 'globe') kickRepaint(map);
|
||||||
@ -345,18 +342,6 @@ export function useGlobeShips(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const before = undefined;
|
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)) {
|
if (!map.getLayer(haloId)) {
|
||||||
try {
|
try {
|
||||||
@ -443,76 +428,6 @@ export function useGlobeShips(
|
|||||||
}
|
}
|
||||||
// outline: data-driven expressions are static — visibility handled by fast toggle
|
// 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)) {
|
if (!map.getLayer(symbolId)) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
@ -520,7 +435,6 @@ export function useGlobeShips(
|
|||||||
id: symbolId,
|
id: symbolId,
|
||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: srcId,
|
source: srcId,
|
||||||
filter: priorityFilter as never,
|
|
||||||
layout: {
|
layout: {
|
||||||
visibility,
|
visibility,
|
||||||
'symbol-sort-key': [
|
'symbol-sort-key': [
|
||||||
@ -561,10 +475,10 @@ export function useGlobeShips(
|
|||||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
'icon-opacity': [
|
'icon-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 1,
|
['==', ['get', 'permitted'], 1], 1,
|
||||||
['==', ['get', 'highlighted'], 1], 0.95,
|
['==', ['get', 'selected'], 1], 0.86,
|
||||||
['==', ['get', 'permitted'], 1], 0.93,
|
['==', ['get', 'highlighted'], 1], 0.82,
|
||||||
0.9,
|
0.66,
|
||||||
] as never,
|
] as never,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
@ -906,14 +820,13 @@ export function useGlobeShips(
|
|||||||
if (projection !== 'globe' || !settings.showShips) return;
|
if (projection !== 'globe' || !settings.showShips) return;
|
||||||
|
|
||||||
const symbolId = 'ships-globe';
|
const symbolId = 'ships-globe';
|
||||||
const symbolLiteId = 'ships-globe-lite';
|
|
||||||
const haloId = 'ships-globe-halo';
|
const haloId = 'ships-globe-halo';
|
||||||
const outlineId = 'ships-globe-outline';
|
const outlineId = 'ships-globe-outline';
|
||||||
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
||||||
|
|
||||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
try {
|
try {
|
||||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id));
|
||||||
let feats: unknown[] = [];
|
let feats: unknown[] = [];
|
||||||
if (layerIds.length > 0) {
|
if (layerIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -84,27 +84,16 @@ export function useNativeMapLayers(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
let cancelled = false;
|
|
||||||
let retryRaf: number | null = null;
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (cancelled) return;
|
|
||||||
const cfg = configRef.current;
|
const cfg = configRef.current;
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
|
||||||
// 1. Visibility 토글
|
// 1. Visibility 토글
|
||||||
for (const spec of cfg.layers) {
|
for (const spec of cfg.layers) {
|
||||||
setLayerVisibility(map, spec.id, cfg.visible);
|
setLayerVisibility(map, spec.id, cfg.visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
// projection transition 중에는 가시성 토글만 먼저 반영하고,
|
|
||||||
// source/layer 업데이트는 transition 종료 후 재시도한다.
|
|
||||||
if (projectionBusyRef.current) {
|
|
||||||
if (cfg.visible) {
|
|
||||||
retryRaf = requestAnimationFrame(ensure);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||||
const hasData = cfg.sources.some((s) => s.data != null);
|
const hasData = cfg.sources.some((s) => s.data != null);
|
||||||
if (!hasData) return;
|
if (!hasData) return;
|
||||||
@ -161,10 +150,6 @@ export function useNativeMapLayers(
|
|||||||
|
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
if (retryRaf != null) {
|
|
||||||
cancelAnimationFrame(retryRaf);
|
|
||||||
}
|
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@ -99,10 +99,6 @@ export function useProjectionToggle(
|
|||||||
'vessel-track-arrow',
|
'vessel-track-arrow',
|
||||||
'vessel-track-pts',
|
'vessel-track-pts',
|
||||||
'vessel-track-pts-highlight',
|
'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-fill',
|
||||||
'zones-line',
|
'zones-line',
|
||||||
'zones-label',
|
'zones-label',
|
||||||
@ -112,7 +108,6 @@ export function useProjectionToggle(
|
|||||||
'predict-vectors-hl',
|
'predict-vectors-hl',
|
||||||
'ships-globe-halo',
|
'ships-globe-halo',
|
||||||
'ships-globe-outline',
|
'ships-globe-outline',
|
||||||
'ships-globe-lite',
|
|
||||||
'ships-globe',
|
'ships-globe',
|
||||||
'ships-globe-label',
|
'ships-globe-label',
|
||||||
'ships-globe-hover-halo',
|
'ships-globe-hover-halo',
|
||||||
@ -121,7 +116,6 @@ export function useProjectionToggle(
|
|||||||
'pair-lines-ml',
|
'pair-lines-ml',
|
||||||
'fc-lines-ml',
|
'fc-lines-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
'fleet-circles-ml-fill',
|
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
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<maplibregl.Map | null>,
|
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
|
||||||
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<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
|
||||||
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
|
||||||
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<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
|
||||||
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<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
|
||||||
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<NativeSourceConfig[]>(
|
|
||||||
() => [
|
|
||||||
{ id: GLOBE_LINE_SRC, data: lineGeoJson },
|
|
||||||
{ id: GLOBE_POINT_SRC, data: pointGeoJson },
|
|
||||||
{ id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson },
|
|
||||||
],
|
|
||||||
[lineGeoJson, pointGeoJson, virtualGeoJson],
|
|
||||||
);
|
|
||||||
|
|
||||||
const globeLayers = useMemo<NativeLayerSpec[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
@ -107,7 +107,6 @@ const GLOBE_LAYERS: NativeLayerSpec[] = [
|
|||||||
const ANIM_CYCLE_SEC = 20;
|
const ANIM_CYCLE_SEC = 20;
|
||||||
|
|
||||||
/* ── Hook ──────────────────────────────────────────────────────────── */
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||||
/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */
|
|
||||||
export function useVesselTrackLayer(
|
export function useVesselTrackLayer(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
|||||||
@ -103,19 +103,6 @@ export function sanitizeDeckLayerList(value: unknown): unknown[] {
|
|||||||
let dropped = 0;
|
let dropped = 0;
|
||||||
|
|
||||||
for (const layer of value) {
|
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);
|
const layerId = getLayerId(layer);
|
||||||
if (!layerId) {
|
if (!layerId) {
|
||||||
dropped += 1;
|
dropped += 1;
|
||||||
|
|||||||
@ -63,11 +63,10 @@ export function getGlobeBaseShipColor({
|
|||||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
|
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)';
|
||||||
if (!isFiniteNumber(sog)) return '#64748b';
|
if (sog >= 10) return 'rgba(148,163,184,0.78)';
|
||||||
if (sog >= 10) return '#94a3b8';
|
if (sog >= 1) return 'rgba(100,116,139,0.74)';
|
||||||
if (sog >= 1) return '#64748b';
|
return 'rgba(71,85,105,0.68)';
|
||||||
return '#475569';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShipColor(
|
export function getShipColor(
|
||||||
|
|||||||
@ -60,7 +60,6 @@ export interface Map3DProps {
|
|||||||
onHoverCable?: (cableId: string | null) => void;
|
onHoverCable?: (cableId: string | null) => void;
|
||||||
onClickCable?: (cableId: string | null) => void;
|
onClickCable?: (cableId: string | null) => void;
|
||||||
mapStyleSettings?: MapStyleSettings;
|
mapStyleSettings?: MapStyleSettings;
|
||||||
onMapReady?: (map: import('maplibre-gl').Map) => void;
|
|
||||||
initialView?: MapViewState | null;
|
initialView?: MapViewState | null;
|
||||||
onViewStateChange?: (view: MapViewState) => void;
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
|
|||||||
@ -1,307 +0,0 @@
|
|||||||
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<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: position.x,
|
|
||||||
top: position.y,
|
|
||||||
width: PANEL_WIDTH,
|
|
||||||
background: 'rgba(15,23,42,0.94)',
|
|
||||||
border: '1px solid rgba(148,163,184,0.35)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
color: '#e2e8f0',
|
|
||||||
zIndex: 40,
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onPointerDown={handleHeaderPointerDown}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 8,
|
|
||||||
cursor: isDragging ? 'grabbing' : 'grab',
|
|
||||||
userSelect: 'none',
|
|
||||||
touchAction: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => closeTrackQuery()}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid rgba(148,163,184,0.5)',
|
|
||||||
background: 'rgba(30,41,59,0.7)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
닫기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
|
||||||
|
|
||||||
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
|
|
||||||
선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (isPlaying ? pause() : play())}
|
|
||||||
disabled={tracks.length === 0}
|
|
||||||
style={{
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid rgba(148,163,184,0.45)',
|
|
||||||
background: 'rgba(30,41,59,0.8)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPlaying ? '일시정지' : '재생'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => stop()}
|
|
||||||
disabled={tracks.length === 0}
|
|
||||||
style={{
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid rgba(148,163,184,0.45)',
|
|
||||||
background: 'rgba(30,41,59,0.8)',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
정지
|
|
||||||
</button>
|
|
||||||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
배속
|
|
||||||
<select
|
|
||||||
value={playbackSpeed}
|
|
||||||
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
|
|
||||||
style={{
|
|
||||||
background: 'rgba(30,41,59,0.85)',
|
|
||||||
border: '1px solid rgba(148,163,184,0.45)',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: '#e2e8f0',
|
|
||||||
fontSize: 12,
|
|
||||||
padding: '4px 6px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
|
||||||
<option key={speed} value={speed}>
|
|
||||||
{speed}x
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 10 }}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={startTime}
|
|
||||||
max={endTime || startTime + 1}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={(event) => setCurrentTime(Number(event.target.value))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
disabled={tracks.length === 0 || endTime <= startTime}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
|
|
||||||
<span>{formatDateTime(currentTime)}</span>
|
|
||||||
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showVirtualShip}
|
|
||||||
onChange={(event) => setShowVirtualShip(event.target.checked)}
|
|
||||||
/>{' '}
|
|
||||||
가상선박
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> 선명
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> 잔상
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={hideLiveShips}
|
|
||||||
onChange={(event) => setHideLiveShips(event.target.checked)}
|
|
||||||
/>{' '}
|
|
||||||
라이브 숨김
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} /> 반복
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
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<number | null>(null);
|
|
||||||
/** 마지막으로 seek 요청한 step index (중복 방지) */
|
|
||||||
const lastSeekedRef = useRef<number | null>(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<HTMLInputElement>) => {
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className={`wo-gear${open ? ' open' : ''}${activeCount > 0 ? ' active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setOpen((prev) => {
|
|
||||||
if (prev && activeCount > 0) {
|
|
||||||
toggleLayer(activeLayerId!);
|
|
||||||
pause();
|
|
||||||
}
|
|
||||||
return !prev;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title="기상 타일 오버레이"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
{activeCount > 0 && <span className="wo-gear-badge">{activeCount}</span>}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(showPanel || showLegend) && (
|
|
||||||
<div className="wo-stack">
|
|
||||||
{showPanel && (
|
|
||||||
<div className="wo-panel">
|
|
||||||
<div className="wo-header">
|
|
||||||
<span className="wo-title">기상 타일 오버레이</span>
|
|
||||||
{!isReady && activeCount > 0 && <span className="wo-loading">로딩...</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wo-layers">
|
|
||||||
{WEATHER_LAYERS.map((meta) => (
|
|
||||||
<button
|
|
||||||
key={meta.id}
|
|
||||||
className={`wo-layer-btn${enabled[meta.id] ? ' on' : ''}`}
|
|
||||||
onClick={() => toggleLayer(meta.id as WeatherLayerId)}
|
|
||||||
title={meta.label}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="wo-layer-icon">{meta.icon}</span>
|
|
||||||
<span className="wo-layer-name">{meta.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wo-section">
|
|
||||||
<div className="wo-label">
|
|
||||||
투명도 <span className="wo-val">{Math.round(opacity * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="wo-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={Math.round(opacity * 100)}
|
|
||||||
onChange={(e) => setOpacity(Number(e.target.value) / 100)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeCount > 0 && steps.length > 0 && (
|
|
||||||
<div className="wo-section wo-timeline">
|
|
||||||
<div className="wo-label">
|
|
||||||
기준 <span className="wo-val">{fmtBase(startTime)}</span>
|
|
||||||
{' · '}
|
|
||||||
<span className="wo-val wo-offset">{fmtOffset(startTime, currentTime)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* step 기반 슬라이더 */}
|
|
||||||
<div className="wo-step-slider-wrap">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="wo-slider wo-time-slider"
|
|
||||||
min={0}
|
|
||||||
max={steps.length - 1}
|
|
||||||
step={1}
|
|
||||||
value={currentStepIdx}
|
|
||||||
onChange={handleSliderInput}
|
|
||||||
onMouseUp={handleSliderCommit}
|
|
||||||
onTouchEnd={handleSliderCommit}
|
|
||||||
disabled={!isReady}
|
|
||||||
/>
|
|
||||||
{/* 눈금 표시 */}
|
|
||||||
<div className="wo-step-ticks">
|
|
||||||
{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 (
|
|
||||||
<span
|
|
||||||
key={sec}
|
|
||||||
className={`wo-step-tick${isDay ? ' day' : ''}`}
|
|
||||||
style={{ left: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wo-time-range">
|
|
||||||
<span>{fmtBase(startTime)}</span>
|
|
||||||
<span>
|
|
||||||
{draggingIdx != null && steps[draggingIdx]
|
|
||||||
? fmtBase(secToDate(steps[draggingIdx]))
|
|
||||||
: fmtBase(currentTime)}
|
|
||||||
</span>
|
|
||||||
<span>{fmtBase(endTime)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wo-playback">
|
|
||||||
<button
|
|
||||||
className="wo-play-btn"
|
|
||||||
type="button"
|
|
||||||
onClick={isPlaying ? pause : play}
|
|
||||||
disabled={!isReady}
|
|
||||||
title={isPlaying ? '일시정지' : '재생'}
|
|
||||||
>
|
|
||||||
{isPlaying ? '⏸' : '▶'}
|
|
||||||
</button>
|
|
||||||
<div className="wo-speed-btns">
|
|
||||||
{SPEED_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
className={`wo-speed-btn${animationSpeed === opt.value ? ' on' : ''}`}
|
|
||||||
onClick={() => setSpeed(opt.value)}
|
|
||||||
type="button"
|
|
||||||
title={`${opt.label} 속도`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="wo-hint">
|
|
||||||
MapTiler Weather SDK
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLegend && (
|
|
||||||
<div className="wo-legend">
|
|
||||||
<div className="wo-legend-header">
|
|
||||||
{legendInfo!.label} ({legendInfo!.unit})
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="wo-legend-bar"
|
|
||||||
style={{ background: legendGradient! }}
|
|
||||||
/>
|
|
||||||
<div className="wo-legend-ticks">
|
|
||||||
{legendTicks.map((t, i) => (
|
|
||||||
<span key={i}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
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 (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className={`weather-gear${open ? ' open' : ''}`}
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
title="해양 기상 현황"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M2 12h2M6 8c0-2.2 1.8-4 4-4 1.5 0 2.8.8 3.5 2h.5c1.7 0 3 1.3 3 3s-1.3 3-3 3H6c-2.2 0-4-1.8-4-4z" />
|
|
||||||
<path d="M2 20h2M8 16a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3 3 3 0 0 1-3 3H8a3 3 0 0 1 0-6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="weather-panel">
|
|
||||||
<div className="wp-header">
|
|
||||||
<span className="wp-title">해양 기상 현황</span>
|
|
||||||
{isLoading && <span className="wp-loading">조회중...</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="wp-error">{error}</div>}
|
|
||||||
|
|
||||||
{snapshot && snapshot.points.map((pt) => {
|
|
||||||
const severity = getWaveSeverity(pt.waveHeight);
|
|
||||||
const isWarn = severity === 'rough' || severity === 'severe'
|
|
||||||
|| (pt.windSpeed != null && pt.windSpeed >= 10);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pt.label}
|
|
||||||
className={`wz-card${isWarn ? ' wz-warn' : ''}`}
|
|
||||||
style={{ borderLeftColor: pt.color }}
|
|
||||||
>
|
|
||||||
<div className="wz-name">{pt.label}</div>
|
|
||||||
<div className="wz-row">
|
|
||||||
<span className="wz-item">
|
|
||||||
<span className="wz-icon">~</span>
|
|
||||||
<span className="wz-value">{fmtVal(pt.waveHeight, 'm')}</span>
|
|
||||||
</span>
|
|
||||||
<span className="wz-item">
|
|
||||||
<span className="wz-icon">{getWindArrow(pt.windDirection)}</span>
|
|
||||||
<span className="wz-value">
|
|
||||||
{fmtVal(pt.windSpeed, 'm/s')} {getWindDirectionLabel(pt.windDirection)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="wz-row">
|
|
||||||
<span className="wz-item">
|
|
||||||
<span className="wz-label">수온</span>
|
|
||||||
<span className="wz-value">{fmtVal(pt.seaSurfaceTemp, '°C')}</span>
|
|
||||||
</span>
|
|
||||||
<span className="wz-item">
|
|
||||||
<span className="wz-label">너울</span>
|
|
||||||
<span className="wz-value">{fmtVal(pt.swellHeight, 'm')}</span>
|
|
||||||
</span>
|
|
||||||
{pt.weatherCode != null && (
|
|
||||||
<span className="wz-item">
|
|
||||||
<span className="wz-value wz-weather">{getWeatherLabel(pt.weatherCode)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!snapshot && !isLoading && !error && (
|
|
||||||
<div className="wp-empty">데이터 없음</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="wp-footer">
|
|
||||||
{snapshot && (
|
|
||||||
<span className="wp-time">갱신 {fmtTime(snapshot.fetchedAt)}</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="wp-refresh"
|
|
||||||
type="button"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
385
package-lock.json
generated
385
package-lock.json
generated
@ -31,18 +31,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||||
"@deck.gl/core": "^9.2.7",
|
"@deck.gl/core": "^9.2.7",
|
||||||
"@deck.gl/extensions": "^9.2.7",
|
|
||||||
"@deck.gl/geo-layers": "^9.2.7",
|
"@deck.gl/geo-layers": "^9.2.7",
|
||||||
"@deck.gl/layers": "^9.2.7",
|
"@deck.gl/layers": "^9.2.7",
|
||||||
"@deck.gl/mapbox": "^9.2.7",
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
"@maptiler/weather": "^3.1.1",
|
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"@stomp/stompjs": "^7.2.1",
|
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0"
|
||||||
"zustand": "^5.0.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@ -1900,114 +1896,6 @@
|
|||||||
"supercluster": "^8.0.1"
|
"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": {
|
"node_modules/@math.gl/core": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
|
||||||
@ -2067,12 +1955,6 @@
|
|||||||
"@math.gl/core": "4.1.0"
|
"@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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@ -2467,12 +2349,6 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@turf/boolean-clockwise": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
||||||
@ -2602,15 +2478,6 @@
|
|||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@ -2639,17 +2506,11 @@
|
|||||||
"integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==",
|
"integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2974,16 +2835,6 @@
|
|||||||
"resolved": "apps/web",
|
"resolved": "apps/web",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@zarrita/storage": {
|
|
||||||
"version": "0.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz",
|
|
||||||
"integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"reference-spec-reader": "^0.2.0",
|
|
||||||
"unzipit": "1.4.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/a5-js": {
|
"node_modules/a5-js": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
|
||||||
@ -3432,7 +3283,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-hexbin": {
|
"node_modules/d3-hexbin": {
|
||||||
@ -3756,15 +3607,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||||
@ -4074,49 +3916,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/geojson-vt": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/geotiff": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@petamoriken/float16": "^3.4.7",
|
|
||||||
"lerc": "^3.0.0",
|
|
||||||
"pako": "^2.0.4",
|
|
||||||
"parse-headers": "^2.0.2",
|
|
||||||
"quick-lru": "^6.1.1",
|
|
||||||
"web-worker": "^1.5.0",
|
|
||||||
"xml-utils": "^1.10.2",
|
|
||||||
"zstddec": "^0.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/geotiff/node_modules/pako": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
|
||||||
"license": "(MIT AND Zlib)"
|
|
||||||
},
|
|
||||||
"node_modules/geotiff/node_modules/quick-lru": {
|
|
||||||
"version": "6.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
|
||||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-stream": {
|
"node_modules/get-stream": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
@ -4350,12 +4149,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -4482,12 +4275,6 @@
|
|||||||
"integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==",
|
"integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lerc": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@ -4731,45 +4518,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/numcodecs": {
|
|
||||||
"version": "0.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
|
||||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fflate": "^0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/numcodecs/node_modules/fflate": {
|
|
||||||
"version": "0.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
|
||||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ol": {
|
|
||||||
"version": "10.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz",
|
|
||||||
"integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/rbush": "4.0.0",
|
|
||||||
"earcut": "^3.0.0",
|
|
||||||
"geotiff": "^3.0.2",
|
|
||||||
"pbf": "4.0.1",
|
|
||||||
"rbush": "^4.0.0",
|
|
||||||
"zarrita": "^0.6.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/openlayers"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ol/node_modules/earcut": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
@ -4848,12 +4596,6 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@ -5033,33 +4775,12 @@
|
|||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/quickselect": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@ -5139,12 +4860,6 @@
|
|||||||
"node": ">= 12.13.0"
|
"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": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@ -5503,12 +5218,6 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@ -5632,18 +5341,6 @@
|
|||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@ -5691,25 +5388,6 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uzip-module": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
@ -5786,12 +5464,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/wgsl_reflect": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
|
||||||
@ -5866,12 +5538,6 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@ -5892,16 +5558,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
@ -5932,41 +5588,6 @@
|
|||||||
"integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==",
|
"integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user