feat(weather): 기상 타일 오버레이 + 수역별 날씨 패널
MapTiler Weather SDK 6종 기상 타일 오버레이: - 바람/기온/강수/기압/레이더/구름 라디오 토글 - 3시간 단위 step 스냅 타임라인 + 드래그 실시간 seek - 색상 범례, 배속 제어, 투명도 조절 - ServiceWorker 타일 캐시 (cache-first, 최대 2000장) - SDK 시간 단위(epoch 초) 정합성 보장 Open-Meteo 수역별 기상 패널: - 4개 수역 centroid 기반 해양/기상 데이터 5분 폴링 - 파고/풍속/수온/너울 카드 UI + 경고 하이라이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
ca43bbcebb
커밋
cb6493b8a1
@ -14,6 +14,7 @@
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@maptiler/weather": "^3.1.1",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"react": "^19.2.0",
|
||||
|
||||
62
apps/web/public/sw-weather-cache.js
Normal file
62
apps/web/public/sw-weather-cache.js
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Weather Tile Cache ServiceWorker
|
||||
*
|
||||
* MapTiler Weather SDK 타일(Image 요소로 로드)을 Cache API로 캐싱.
|
||||
* 같은 tileset_id + z/x/y 좌표 → 동일 URL → cache-first 전략.
|
||||
*
|
||||
* 캐시 최대 2000장, 초과 시 가장 오래된 항목부터 제거.
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'weather-tiles-v1';
|
||||
const MAX_ENTRIES = 2000;
|
||||
|
||||
/** api.maptiler.com/tiles/ 패턴만 캐싱 */
|
||||
const TILE_RE = /api\.maptiler\.com\/tiles\//;
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k.startsWith('weather-tiles-') && k !== CACHE_NAME)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = event.request.url;
|
||||
if (!TILE_RE.test(url)) return; // 타일 외 요청은 패스스루
|
||||
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
// 1. 캐시 히트 → 즉시 반환
|
||||
const cached = await cache.match(event.request);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. 네트워크 fetch
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok) {
|
||||
// 비동기 캐시 저장 (응답 지연 없음)
|
||||
cache.put(event.request, response.clone()).then(() => trimCache(cache));
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/** 캐시 항목이 MAX_ENTRIES 초과 시 오래된 것부터 삭제 */
|
||||
async function trimCache(cache) {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= MAX_ENTRIES) return;
|
||||
const excess = keys.length - MAX_ENTRIES;
|
||||
for (let i = 0; i < excess; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
@ -1258,6 +1258,549 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Weather Overlay Panel ─────────────────────────────────── */
|
||||
|
||||
.weather-gear {
|
||||
position: absolute;
|
||||
top: 140px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.weather-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.weather-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.weather-panel {
|
||||
position: absolute;
|
||||
top: 130px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
width: 260px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wp-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.wp-loading {
|
||||
font-size: 9px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wp-error {
|
||||
font-size: 9px;
|
||||
color: #f87171;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wp-empty {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.wz-card {
|
||||
border-left: 3px solid var(--border);
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wz-card:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wz-card.wz-warn {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
.wz-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wz-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wz-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wz-icon {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wz-label {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wz-value {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wz-weather {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wp-time {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wp-refresh {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wp-refresh:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wp-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */
|
||||
|
||||
.wo-gear {
|
||||
position: absolute;
|
||||
top: 180px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wo-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear.active {
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.wo-gear.active.open {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-gear-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wo-stack {
|
||||
position: absolute;
|
||||
top: 170px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 280px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wo-stack > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wo-panel {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.wo-loading {
|
||||
font-size: 9px;
|
||||
color: var(--accent);
|
||||
animation: wo-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wo-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.wo-layers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-layer-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wo-layer-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.wo-layer-btn.on {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-layer-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wo-layer-name {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wo-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wo-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wo-val {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.wo-offset {
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wo-slider {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wo-slider:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wo-timeline {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wo-step-slider-wrap {
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.wo-time-slider {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wo-step-ticks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wo-step-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--muted);
|
||||
opacity: 0.4;
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
.wo-step-tick.day {
|
||||
height: 8px;
|
||||
opacity: 0.8;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wo-time-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wo-playback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wo-play-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wo-play-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.wo-play-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wo-speed-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wo-speed-btn {
|
||||
font-size: 8px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.wo-speed-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.wo-speed-btn.on {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wo-hint {
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Weather Legend ── */
|
||||
.wo-legend {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wo-legend-header {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.wo-legend-bar {
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.wo-legend-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
107
apps/web/src/entities/weather/api/fetchWeather.ts
Normal file
107
apps/web/src/entities/weather/api/fetchWeather.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { WeatherQueryPoint, WeatherPoint, WeatherSnapshot } from '../model/types';
|
||||
|
||||
const MARINE_BASE = 'https://marine-api.open-meteo.com/v1/marine';
|
||||
const WEATHER_BASE = 'https://api.open-meteo.com/v1/forecast';
|
||||
|
||||
const MARINE_PARAMS = 'current=wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction,sea_surface_temperature';
|
||||
const WEATHER_PARAMS = 'current=wind_speed_10m,wind_direction_10m,wind_gusts_10m,temperature_2m,weather_code';
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
/* Open-Meteo 다중 좌표 응답 타입 */
|
||||
interface MarineCurrentItem {
|
||||
wave_height?: number;
|
||||
wave_direction?: number;
|
||||
wave_period?: number;
|
||||
swell_wave_height?: number;
|
||||
swell_wave_direction?: number;
|
||||
sea_surface_temperature?: number;
|
||||
}
|
||||
|
||||
interface WeatherCurrentItem {
|
||||
wind_speed_10m?: number;
|
||||
wind_direction_10m?: number;
|
||||
wind_gusts_10m?: number;
|
||||
temperature_2m?: number;
|
||||
weather_code?: number;
|
||||
}
|
||||
|
||||
/* 단일 좌표 응답 */
|
||||
interface SingleMarineResponse {
|
||||
current?: MarineCurrentItem;
|
||||
}
|
||||
|
||||
/* 다중 좌표 응답 — 배열 */
|
||||
type MarineResponse = SingleMarineResponse | SingleMarineResponse[];
|
||||
|
||||
interface SingleWeatherResponse {
|
||||
current?: WeatherCurrentItem;
|
||||
}
|
||||
|
||||
type WeatherResponse = SingleWeatherResponse | SingleWeatherResponse[];
|
||||
|
||||
function buildMultiCoordParams(points: WeatherQueryPoint[]): string {
|
||||
const lats = points.map((p) => p.lat.toFixed(2)).join(',');
|
||||
const lons = points.map((p) => p.lon.toFixed(2)).join(',');
|
||||
return `latitude=${lats}&longitude=${lons}`;
|
||||
}
|
||||
|
||||
function n(v: number | undefined): number | null {
|
||||
return v != null && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-Meteo Marine + Weather API를 병렬 호출하여 기상 스냅샷 반환.
|
||||
* 좌표 배열 기반이므로 수역 centroid 외에도 임의 좌표에 활용 가능.
|
||||
*/
|
||||
export async function fetchWeatherForPoints(
|
||||
points: WeatherQueryPoint[],
|
||||
): Promise<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);
|
||||
}
|
||||
}
|
||||
78
apps/web/src/entities/weather/lib/weatherUtils.ts
Normal file
78
apps/web/src/entities/weather/lib/weatherUtils.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/** 기상 데이터 유틸리티 */
|
||||
|
||||
const DIRECTION_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
|
||||
const DIRECTION_ARROWS = ['↓', '↙', '←', '↖', '↑', '↗', '→', '↘'] as const;
|
||||
|
||||
/**
|
||||
* 풍향 각도 → 8방위 라벨.
|
||||
* 풍향은 "바람이 불어오는 방향"이므로 0° = 북풍(N).
|
||||
*/
|
||||
export function getWindDirectionLabel(deg: number | null): string {
|
||||
if (deg == null) return '-';
|
||||
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
|
||||
return DIRECTION_LABELS[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* 풍향 각도 → 화살표 문자.
|
||||
* 바람이 불어가는 방향을 가리킴 (풍향의 반대).
|
||||
*/
|
||||
export function getWindArrow(deg: number | null): string {
|
||||
if (deg == null) return '';
|
||||
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
|
||||
return DIRECTION_ARROWS[idx];
|
||||
}
|
||||
|
||||
export type WaveSeverity = 'calm' | 'moderate' | 'rough' | 'severe';
|
||||
|
||||
/** 파고 등급 분류 */
|
||||
export function getWaveSeverity(m: number | null): WaveSeverity {
|
||||
if (m == null || m < 0.5) return 'calm';
|
||||
if (m < 1.5) return 'moderate';
|
||||
if (m < 2.5) return 'rough';
|
||||
return 'severe';
|
||||
}
|
||||
|
||||
/**
|
||||
* WMO Weather Interpretation Code → 한글 라벨.
|
||||
* https://open-meteo.com/en/docs 참조.
|
||||
*/
|
||||
export function getWeatherLabel(code: number | null): string {
|
||||
if (code == null) return '-';
|
||||
if (code === 0) return '맑음';
|
||||
if (code <= 3) return ['약간 흐림', '흐림', '매우 흐림'][code - 1];
|
||||
if (code <= 49) return '안개';
|
||||
if (code <= 59) return '이슬비';
|
||||
if (code <= 69) return '비';
|
||||
if (code <= 79) return '눈';
|
||||
if (code <= 84) return '소나기';
|
||||
if (code <= 94) return '뇌우';
|
||||
if (code <= 99) return '뇌우(우박)';
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiPolygon 좌표 배열에서 산술 평균 centroid 계산.
|
||||
* GeoJSON MultiPolygon: number[][][][]
|
||||
*/
|
||||
export function computeMultiPolygonCentroid(
|
||||
coordinates: number[][][][],
|
||||
): [number, number] {
|
||||
let sumLon = 0;
|
||||
let sumLat = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const polygon of coordinates) {
|
||||
// 외곽 링만 사용 (polygon[0])
|
||||
const ring = polygon[0];
|
||||
if (!ring) continue;
|
||||
for (const coord of ring) {
|
||||
sumLon += coord[0];
|
||||
sumLat += coord[1];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return [0, 0];
|
||||
return [sumLon / count, sumLat / count];
|
||||
}
|
||||
40
apps/web/src/entities/weather/model/types.ts
Normal file
40
apps/web/src/entities/weather/model/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { ZoneId } from '../../zone/model/meta';
|
||||
|
||||
/** 기상 조회 대상 지점 (입력용) */
|
||||
export interface WeatherQueryPoint {
|
||||
label: string;
|
||||
color: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
zoneId?: ZoneId;
|
||||
}
|
||||
|
||||
/** 단일 지점의 기상 데이터 */
|
||||
export interface WeatherPoint {
|
||||
label: string;
|
||||
color: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
zoneId?: ZoneId;
|
||||
|
||||
/* Marine */
|
||||
waveHeight: number | null;
|
||||
waveDirection: number | null;
|
||||
wavePeriod: number | null;
|
||||
swellHeight: number | null;
|
||||
swellDirection: number | null;
|
||||
seaSurfaceTemp: number | null;
|
||||
|
||||
/* Weather */
|
||||
windSpeed: number | null;
|
||||
windDirection: number | null;
|
||||
windGusts: number | null;
|
||||
temperature: number | null;
|
||||
weatherCode: number | null;
|
||||
}
|
||||
|
||||
/** 기상 스냅샷 (전체 지점 묶음) */
|
||||
export interface WeatherSnapshot {
|
||||
points: WeatherPoint[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
415
apps/web/src/features/weatherOverlay/useWeatherOverlay.ts
Normal file
415
apps/web/src/features/weatherOverlay/useWeatherOverlay.ts
Normal file
@ -0,0 +1,415 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { config as maptilerConfig } from '@maptiler/sdk';
|
||||
import {
|
||||
WindLayer,
|
||||
TemperatureLayer,
|
||||
PrecipitationLayer,
|
||||
PressureLayer,
|
||||
RadarLayer,
|
||||
ColorRamp,
|
||||
} from '@maptiler/weather';
|
||||
import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore';
|
||||
|
||||
/** 6종 기상 레이어 ID */
|
||||
export type WeatherLayerId =
|
||||
| 'wind'
|
||||
| 'temperature'
|
||||
| 'precipitation'
|
||||
| 'pressure'
|
||||
| 'radar'
|
||||
| 'clouds';
|
||||
|
||||
export interface WeatherLayerMeta {
|
||||
id: WeatherLayerId;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const WEATHER_LAYERS: WeatherLayerMeta[] = [
|
||||
{ id: 'wind', label: '바람', icon: '💨' },
|
||||
{ id: 'temperature', label: '기온', icon: '🌡' },
|
||||
{ id: 'precipitation', label: '강수', icon: '🌧' },
|
||||
{ id: 'pressure', label: '기압', icon: '◎' },
|
||||
{ id: 'radar', label: '레이더', icon: '📡' },
|
||||
{ id: 'clouds', label: '구름', icon: '☁' },
|
||||
];
|
||||
|
||||
const LAYER_ID_PREFIX = 'maptiler-weather-';
|
||||
|
||||
/** 한중일 + 남중국해 영역 [west, south, east, north] */
|
||||
const TILE_BOUNDS: [number, number, number, number] = [100, 10, 150, 50];
|
||||
|
||||
type AnyWeatherLayer = WindLayer | TemperatureLayer | PrecipitationLayer | PressureLayer | RadarLayer;
|
||||
|
||||
const DEFAULT_ENABLED: Record<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,
|
||||
};
|
||||
}
|
||||
97
apps/web/src/features/weatherOverlay/useWeatherPolling.ts
Normal file
97
apps/web/src/features/weatherOverlay/useWeatherPolling.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import { ZONE_META, type ZoneId } from '../../entities/zone/model/meta';
|
||||
import type { WeatherQueryPoint, WeatherSnapshot } from '../../entities/weather/model/types';
|
||||
import { fetchWeatherForPoints } from '../../entities/weather/api/fetchWeather';
|
||||
import { computeMultiPolygonCentroid } from '../../entities/weather/lib/weatherUtils';
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5분
|
||||
|
||||
/**
|
||||
* zones GeoJSON에서 조회 지점(centroid) 목록 생성.
|
||||
* zoneId 속성이 있는 Feature만 사용.
|
||||
*/
|
||||
function buildQueryPoints(zones: ZonesGeoJson): WeatherQueryPoint[] {
|
||||
const points: WeatherQueryPoint[] = [];
|
||||
|
||||
for (const feature of zones.features) {
|
||||
const zoneId = feature.properties?.zoneId as ZoneId | undefined;
|
||||
if (!zoneId || !ZONE_META[zoneId]) continue;
|
||||
|
||||
const meta = ZONE_META[zoneId];
|
||||
const [lon, lat] = computeMultiPolygonCentroid(
|
||||
feature.geometry.coordinates as number[][][][],
|
||||
);
|
||||
|
||||
points.push({
|
||||
label: `${meta.label} ${meta.name}`,
|
||||
color: meta.color,
|
||||
lat,
|
||||
lon,
|
||||
zoneId,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export interface WeatherPollingResult {
|
||||
snapshot: WeatherSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수역 기상 데이터를 5분 간격으로 폴링하는 훅.
|
||||
* zonesGeoJson이 null이면 대기 (아직 로딩 안 된 경우).
|
||||
*/
|
||||
export function useWeatherPolling(
|
||||
zonesGeoJson: ZonesGeoJson | null,
|
||||
): WeatherPollingResult {
|
||||
const [snapshot, setSnapshot] = useState<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 };
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "../../shared/auth";
|
||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||
@ -26,6 +26,10 @@ import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
|
||||
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
||||
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
||||
@ -80,6 +84,12 @@ export function DashboardPage() {
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
const { data: subcableData } = useSubcables();
|
||||
const legacyIndex = useLegacyIndex(legacyData);
|
||||
const weather = useWeatherPolling(zones);
|
||||
const [mapInstance, setMapInstance] = useState<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 [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||
@ -732,8 +742,16 @@ export function DashboardPage() {
|
||||
onHoverCable={setHoveredCableId}
|
||||
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
||||
mapStyleSettings={mapStyleSettings}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
<WeatherPanel
|
||||
snapshot={weather.snapshot}
|
||||
isLoading={weather.isLoading}
|
||||
error={weather.error}
|
||||
onRefresh={weather.refresh}
|
||||
/>
|
||||
<WeatherOverlayPanel {...weatherOverlay} />
|
||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||
<MapLegend />
|
||||
{selectedLegacyVessel ? (
|
||||
|
||||
@ -66,6 +66,7 @@ export function Map3D({
|
||||
onHoverCable,
|
||||
onClickCable,
|
||||
mapStyleSettings,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
void onHoverFleet;
|
||||
void onClearFleetHover;
|
||||
@ -528,5 +529,13 @@ export function Map3D({
|
||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||
);
|
||||
|
||||
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
||||
const mapReadyFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mapReadyFiredRef.current || !onMapReady || !mapRef.current) return;
|
||||
mapReadyFiredRef.current = true;
|
||||
onMapReady(mapRef.current);
|
||||
}, [mapSyncEpoch, onMapReady]);
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
@ -95,6 +95,7 @@ export function useMapInit(
|
||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
mapRef.current = map;
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
|
||||
if (projectionRef.current === 'mercator') {
|
||||
const overlay = ensureMercatorOverlay();
|
||||
|
||||
@ -52,6 +52,7 @@ export interface Map3DProps {
|
||||
onHoverCable?: (cableId: string | null) => void;
|
||||
onClickCable?: (cableId: string | null) => void;
|
||||
mapStyleSettings?: MapStyleSettings;
|
||||
onMapReady?: (map: import('maplibre-gl').Map) => void;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
|
||||
316
apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx
Normal file
316
apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx
Normal file
@ -0,0 +1,316 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
WEATHER_LAYERS,
|
||||
LEGEND_META,
|
||||
SPEED_OPTIONS,
|
||||
type WeatherLayerId,
|
||||
type WeatherOverlayState,
|
||||
type WeatherOverlayActions,
|
||||
} from '../../features/weatherOverlay/useWeatherOverlay';
|
||||
|
||||
type Props = WeatherOverlayState & WeatherOverlayActions;
|
||||
|
||||
/** 절대 시간 표기 (MM. DD. HH:mm) */
|
||||
function fmtBase(d: Date | null): string {
|
||||
if (!d) return '-';
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}. ${dd}. ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
/** epoch 초 → Date */
|
||||
function secToDate(sec: number): Date {
|
||||
return new Date(sec * 1000);
|
||||
}
|
||||
|
||||
/** 기준시간 대비 오프셋 표기 (+HH:MM) */
|
||||
function fmtOffset(base: Date | null, target: Date | null): string {
|
||||
if (!base || !target) return '-';
|
||||
const diffMs = target.getTime() - base.getTime();
|
||||
const totalMin = Math.round(diffMs / 60_000);
|
||||
const h = Math.floor(Math.abs(totalMin) / 60);
|
||||
const m = Math.abs(totalMin) % 60;
|
||||
const sign = totalMin >= 0 ? '+' : '-';
|
||||
return `${sign}${h}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** ColorRamp → CSS linear-gradient */
|
||||
function rampToGradient(layerId: WeatherLayerId): string {
|
||||
const { colorRamp } = LEGEND_META[layerId];
|
||||
const stops = colorRamp.getRawColorStops();
|
||||
if (stops.length === 0) return 'transparent';
|
||||
const { min, max } = colorRamp.getBounds();
|
||||
const range = max - min || 1;
|
||||
const css = stops.map((s) => {
|
||||
const [r, g, b, a] = s.color;
|
||||
const pct = ((s.value - min) / range) * 100;
|
||||
return `rgba(${r},${g},${b},${(a ?? 255) / 255}) ${pct.toFixed(1)}%`;
|
||||
});
|
||||
return `linear-gradient(to right, ${css.join(', ')})`;
|
||||
}
|
||||
|
||||
/** 범례 눈금 생성 */
|
||||
function getLegendTicks(layerId: WeatherLayerId): string[] {
|
||||
const { colorRamp, unit } = LEGEND_META[layerId];
|
||||
const { min, max } = colorRamp.getBounds();
|
||||
const count = 5;
|
||||
const step = (max - min) / count;
|
||||
const ticks: string[] = [];
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const v = min + step * i;
|
||||
ticks.push(Number.isInteger(v) ? String(v) : v.toFixed(1));
|
||||
}
|
||||
ticks[ticks.length - 1] += unit;
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** steps 배열에서 value 에 가장 가까운 step epoch 초 반환 */
|
||||
function snapToStep(steps: number[], value: number): number {
|
||||
if (steps.length === 0) return value;
|
||||
let best = steps[0];
|
||||
let bestDist = Math.abs(value - best);
|
||||
for (let i = 1; i < steps.length; i++) {
|
||||
const dist = Math.abs(value - steps[i]);
|
||||
if (dist < bestDist) {
|
||||
best = steps[i];
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function WeatherOverlayPanel(props: Props) {
|
||||
const {
|
||||
enabled,
|
||||
activeLayerId,
|
||||
opacity,
|
||||
isPlaying,
|
||||
animationSpeed,
|
||||
currentTime,
|
||||
startTime,
|
||||
endTime,
|
||||
steps,
|
||||
isReady,
|
||||
toggleLayer,
|
||||
setOpacity,
|
||||
play,
|
||||
pause,
|
||||
setSpeed,
|
||||
seekTo,
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeCount = Object.values(enabled).filter(Boolean).length;
|
||||
/** 드래그 중 현재 step index */
|
||||
const [draggingIdx, setDraggingIdx] = useState<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/widgets/weatherPanel/WeatherPanel.tsx
Normal file
118
apps/web/src/widgets/weatherPanel/WeatherPanel.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import type { WeatherSnapshot } from '../../entities/weather/model/types';
|
||||
import {
|
||||
getWindDirectionLabel,
|
||||
getWindArrow,
|
||||
getWaveSeverity,
|
||||
getWeatherLabel,
|
||||
} from '../../entities/weather/lib/weatherUtils';
|
||||
|
||||
interface WeatherPanelProps {
|
||||
snapshot: WeatherSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtVal(v: number | null, unit: string, decimals = 1): string {
|
||||
if (v == null) return '-';
|
||||
return `${v.toFixed(decimals)}${unit}`;
|
||||
}
|
||||
|
||||
export function WeatherPanel({ snapshot, isLoading, error, onRefresh }: WeatherPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
341
package-lock.json
generated
341
package-lock.json
generated
@ -33,6 +33,7 @@
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@maptiler/weather": "^3.1.1",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"react": "^19.2.0",
|
||||
@ -1511,6 +1512,114 @@
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/client": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@maptiler/client/-/client-2.4.0.tgz",
|
||||
"integrity": "sha512-94g2zmGBfgSIY0iJhrANDUfANTH7G/NNZ8mScLLEE+LZfAtaehQ4cvd/EwtuiW3x7ANy92Vzh9bVaKr6xQTSmA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"quick-lru": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/sdk": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@maptiler/sdk/-/sdk-3.5.0.tgz",
|
||||
"integrity": "sha512-Z/OV9+iEGBc9eac2HVsbbV36645xaROIjHaOj7D/QGM3p1Z52A+//Uls55WTNF8/Y2iBBIQxWAb7zeKqtXAArg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-style-spec": "~23.3.0",
|
||||
"@maptiler/client": "~2.4.0",
|
||||
"events": "^3.3.0",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"js-base64": "^3.7.7",
|
||||
"maplibre-gl": "~5.6.0",
|
||||
"uuid": "^11.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/sdk/node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "23.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.3.0.tgz",
|
||||
"integrity": "sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/sdk/node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@maptiler/sdk/node_modules/maplibre-gl": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz",
|
||||
"integrity": "sha512-SEqYThhUCFf6Lm0TckpgpKnto5u4JsdPYdFJb6g12VtuaFsm3nYdBO+fOmnUYddc8dXihgoGnuXvPPooUcRv5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^23.3.0",
|
||||
"@maplibre/vt-pbf": "^4.0.3",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "3.2.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"earcut": "^3.0.2",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/weather": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maptiler/weather/-/weather-3.1.1.tgz",
|
||||
"integrity": "sha512-PB4JUfzb/nwiNzjr4/visFcxIVSw8b2nZNRrNBSay8rmrzBB2DZvx1Me3mB8nOPJtvFQ8wDOTlNnDV6yJvgY9A==",
|
||||
"dependencies": {
|
||||
"@maptiler/sdk": "~3.5.0",
|
||||
"events": "^3.3.0",
|
||||
"lru-cache": "^11.0.2",
|
||||
"ol": "^10.3.1",
|
||||
"three": "~0.135.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maptiler/weather/node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@math.gl/core": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
|
||||
@ -1550,6 +1659,12 @@
|
||||
"@math.gl/core": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@ -2002,6 +2117,15 @@
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@ -2025,6 +2149,12 @@
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/rbush": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -2354,6 +2484,16 @@
|
||||
"resolved": "apps/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@zarrita/storage": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz",
|
||||
"integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
"unzipit": "1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@ -3022,6 +3162,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
@ -3207,6 +3356,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@ -3307,6 +3462,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/geotiff": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz",
|
||||
"integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.4.7",
|
||||
"lerc": "^3.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"parse-headers": "^2.0.2",
|
||||
"quick-lru": "^6.1.1",
|
||||
"web-worker": "^1.5.0",
|
||||
"xml-utils": "^1.10.2",
|
||||
"zstddec": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19"
|
||||
}
|
||||
},
|
||||
"node_modules/geotiff/node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
@ -3467,6 +3659,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -3575,6 +3773,12 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -3776,6 +3980,39 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/numcodecs": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ol": {
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz",
|
||||
"integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
"earcut": "^3.0.0",
|
||||
"geotiff": "^3.0.2",
|
||||
"pbf": "4.0.1",
|
||||
"rbush": "^4.0.0",
|
||||
"zarrita": "^0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/ol/node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
@ -3835,6 +4072,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -3848,6 +4091,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -4021,12 +4270,33 @@
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz",
|
||||
"integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@ -4091,6 +4361,12 @@
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reference-spec-reader": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@ -4382,6 +4658,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.135.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.135.0.tgz",
|
||||
"integrity": "sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -4506,6 +4788,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unzipit": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz",
|
||||
"integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uzip-module": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@ -4547,6 +4841,25 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uzip-module": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz",
|
||||
"integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@ -4623,6 +4936,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wgsl_reflect": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
|
||||
@ -4697,6 +5016,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@ -4717,6 +5042,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zarrita": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz",
|
||||
"integrity": "sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.1.4",
|
||||
"numcodecs": "^0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
@ -4740,6 +5075,12 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zstddec": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user