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 '../../shared/lib/map/mapTilerKey'; /** 6종 기상 레이어 ID */ export type WeatherLayerId = | 'wind' | 'temperature' | 'precipitation' | 'pressure' | 'radar' | 'clouds'; export interface WeatherLayerMeta { id: WeatherLayerId; label: string; icon: string; } export const WEATHER_LAYERS: WeatherLayerMeta[] = [ { id: 'wind', label: '바람', icon: '💨' }, { id: 'temperature', label: '기온', icon: '🌡' }, { id: 'precipitation', label: '강수', icon: '🌧' }, { id: 'pressure', label: '기압', icon: '◎' }, { id: 'radar', label: '레이더', icon: '📡' }, { id: 'clouds', label: '구름', icon: '☁' }, ]; const LAYER_ID_PREFIX = 'maptiler-weather-'; /** 한중일 + 남중국해 영역 [west, south, east, north] */ const TILE_BOUNDS: [number, number, number, number] = [100, 10, 150, 50]; type AnyWeatherLayer = WindLayer | TemperatureLayer | PrecipitationLayer | PressureLayer | RadarLayer; const DEFAULT_ENABLED: Record = { wind: false, temperature: false, precipitation: false, pressure: false, radar: false, clouds: false, }; /** 각 레이어별 범례 정보 */ export interface LegendInfo { label: string; unit: string; colorRamp: ColorRamp; } export const LEGEND_META: Record = { wind: { label: '풍속', unit: 'm/s', colorRamp: ColorRamp.builtin.WIND_ROCKET }, temperature: { label: '기온', unit: '°C', colorRamp: ColorRamp.builtin.TEMPERATURE_3 }, precipitation: { label: '강수량', unit: 'mm/h', colorRamp: ColorRamp.builtin.PRECIPITATION }, pressure: { label: '기압', unit: 'hPa', colorRamp: ColorRamp.builtin.PRESSURE_2 }, radar: { label: '레이더', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR }, clouds: { label: '구름', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR_CLOUD }, }; /** * 배속 옵션. * animateByFactor(value) → 실시간 1초당 value초 진행. * 3600 = 1시간/초, 7200 = 2시간/초 ... */ export const SPEED_OPTIONS = [ { value: 1800, label: '30분/초' }, { value: 3600, label: '1시간/초' }, { value: 7200, label: '2시간/초' }, { value: 14400, label: '4시간/초' }, ]; // bounds는 TileLayerOptions에 정의되나 개별 레이어 생성자 타입에 누락되어 as any 필요 /* eslint-disable @typescript-eslint/no-explicit-any */ function createLayerInstance(layerId: WeatherLayerId, opacity: number): AnyWeatherLayer { const id = `${LAYER_ID_PREFIX}${layerId}`; const opts = { id, opacity, bounds: TILE_BOUNDS }; switch (layerId) { case 'wind': return new WindLayer({ ...opts, colorramp: ColorRamp.builtin.WIND_ROCKET, speed: 0.001, fadeFactor: 0.03, maxAmount: 256, density: 4, fastColor: [255, 100, 80, 230], } as any); case 'temperature': return new TemperatureLayer({ ...opts, colorramp: ColorRamp.builtin.TEMPERATURE_3, } as any); case 'precipitation': return new PrecipitationLayer({ ...opts, colorramp: ColorRamp.builtin.PRECIPITATION, } as any); case 'pressure': return new PressureLayer({ ...opts, colorramp: ColorRamp.builtin.PRESSURE_2, } as any); case 'radar': return new RadarLayer({ ...opts, colorramp: ColorRamp.builtin.RADAR, } as any); case 'clouds': return new RadarLayer({ ...opts, colorramp: ColorRamp.builtin.RADAR_CLOUD, } as any); } } /* eslint-enable @typescript-eslint/no-explicit-any */ /** 타임라인 step 간격 (3시간 = 10 800초) */ const STEP_INTERVAL_SEC = 3 * 3600; /** start~end 를 STEP_INTERVAL_SEC 단위로 나눈 epoch-초 배열 */ function buildSteps(startSec: number, endSec: number): number[] { const steps: number[] = []; for (let t = startSec; t <= endSec; t += STEP_INTERVAL_SEC) { steps.push(t); } // 마지막 step이 endSec 와 다르면 보정 if (steps.length > 0 && steps[steps.length - 1] < endSec) { steps.push(endSec); } return steps; } export interface WeatherOverlayState { enabled: Record; activeLayerId: WeatherLayerId | null; opacity: number; isPlaying: boolean; animationSpeed: number; currentTime: Date | null; startTime: Date | null; endTime: Date | null; /** step epoch(초) 배열 — 타임라인 눈금 */ steps: number[]; isReady: boolean; } export interface WeatherOverlayActions { toggleLayer: (id: WeatherLayerId) => void; setOpacity: (v: number) => void; play: () => void; pause: () => void; setSpeed: (factor: number) => void; /** epoch 초 단위로 seek (SDK 내부 시간 = 초) */ seekTo: (epochSec: number) => void; } /** * MapTiler Weather SDK 6종 오버레이 레이어를 관리하는 훅. * map 인스턴스가 null이면 대기, 값이 설정되면 레이어 추가/제거 활성화. */ export function useWeatherOverlay( map: maplibregl.Map | null, ): WeatherOverlayState & WeatherOverlayActions { const [enabled, setEnabled] = useState>({ ...DEFAULT_ENABLED }); const [opacity, setOpacityState] = useState(0.6); const [isPlaying, setIsPlaying] = useState(false); const [animationSpeed, setAnimationSpeed] = useState(3600); const [currentTime, setCurrentTime] = useState(null); const [startTime, setStartTime] = useState(null); const [endTime, setEndTime] = useState(null); const [isReady, setIsReady] = useState(false); const [steps, setSteps] = useState([]); const layerInstancesRef = useRef>(new Map()); const apiKeySetRef = useRef(false); /** SDK raw 시간 범위 (초 단위) */ const animRangeRef = useRef<{ start: number; end: number } | null>(null); // 레이어 add effect 안의 async 콜백에서 최신 isPlaying/animationSpeed를 읽기 위한 ref const isPlayingRef = useRef(isPlaying); isPlayingRef.current = isPlaying; const animationSpeedRef = useRef(animationSpeed); animationSpeedRef.current = animationSpeed; // API key 설정 + ServiceWorker 등록 useEffect(() => { if (apiKeySetRef.current) return; const key = getMapTilerKey(); if (key) { maptilerConfig.apiKey = key; apiKeySetRef.current = true; } // 타일 캐시 SW 등록 (실패해도 무시 — 캐시 없이도 동작) if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw-weather-cache.js').catch(() => {}); } }, []); // maplibre-gl Map에 MapTiler SDK 전용 메서드/프로퍼티 패치 useEffect(() => { if (!map) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const m = map as any; if (typeof m.getSdkConfig === 'function') return; m.getSdkConfig = () => maptilerConfig; m.getMaptilerSessionId = () => ''; m.isGlobeProjection = () => map.getProjection?.()?.type === 'globe'; if (!m.telemetry) { m.telemetry = { registerModule: () => {} }; } }, [map]); // enabled 변경 시 레이어 추가/제거 useEffect(() => { if (!map) return; if (!apiKeySetRef.current) return; const instances = layerInstancesRef.current; for (const meta of WEATHER_LAYERS) { const isOn = enabled[meta.id]; const existing = instances.get(meta.id); if (isOn && !existing) { const layer = createLayerInstance(meta.id, opacity); instances.set(meta.id, layer); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any map.addLayer(layer as any); // 소스가 준비되면 시간 범위 설정 + 재생 상태 적용 layer.onSourceReadyAsync().then(() => { if (!instances.has(meta.id)) return; // SDK 내부 시간은 epoch 초 단위 const rawStart = layer.getAnimationStart(); const rawEnd = layer.getAnimationEnd(); animRangeRef.current = { start: rawStart, end: rawEnd }; setStartTime(layer.getAnimationStartDate()); setEndTime(layer.getAnimationEndDate()); setSteps(buildSteps(rawStart, rawEnd)); // 시작 시간으로 초기화 (초 단위 전달) layer.setAnimationTime(rawStart); setCurrentTime(layer.getAnimationStartDate()); setIsReady(true); // 재생 중이었다면 새 레이어에도 재생 적용 if (isPlayingRef.current) { layer.animateByFactor(animationSpeedRef.current); } }); } catch (e) { if (import.meta.env.DEV) { console.warn(`[WeatherOverlay] Failed to add layer ${meta.id}:`, e); } instances.delete(meta.id); } } else if (!isOn && existing) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any if (map.getLayer((existing as any).id)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any map.removeLayer((existing as any).id); } } catch { // ignore } instances.delete(meta.id); } } if (instances.size === 0) { setIsReady(false); setStartTime(null); setEndTime(null); setCurrentTime(null); setSteps([]); animRangeRef.current = null; } }, [enabled, map, opacity]); // opacity 변경 시 기존 레이어에 반영 useEffect(() => { for (const layer of layerInstancesRef.current.values()) { layer.setOpacity(opacity); } }, [opacity]); // 애니메이션 상태 동기화 useEffect(() => { for (const layer of layerInstancesRef.current.values()) { if (isPlaying) { layer.animateByFactor(animationSpeed); } else { layer.animateByFactor(0); } } }, [isPlaying, animationSpeed]); // 재생 중 rAF 폴링으로 currentTime 동기화 (~4fps) useEffect(() => { if (!isPlaying) return; const instances = layerInstancesRef.current; if (instances.size === 0) return; let rafId: number; let lastUpdate = 0; const poll = () => { const now = performance.now(); if (now - lastUpdate > 250) { lastUpdate = now; const first = instances.values().next().value; if (first) { setCurrentTime(first.getAnimationTimeDate()); } } rafId = requestAnimationFrame(poll); }; rafId = requestAnimationFrame(poll); return () => cancelAnimationFrame(rafId); }, [isPlaying]); // cleanup on map change or unmount useEffect(() => { return () => { for (const [id, layer] of layerInstancesRef.current) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any if (map?.getLayer((layer as any).id)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any map.removeLayer((layer as any).id); } } catch { // ignore } void id; } layerInstancesRef.current.clear(); }; }, [map]); // 라디오 버튼 동작: 하나만 활성, 다시 누르면 전부 off const toggleLayer = useCallback((id: WeatherLayerId) => { setEnabled((prev) => { const next = { ...DEFAULT_ENABLED }; if (!prev[id]) next[id] = true; return next; }); // 레이어 전환 시 isReady 리셋 (새 소스 로딩 대기) setIsReady(false); }, []); const setOpacity = useCallback((v: number) => { setOpacityState(Math.max(0, Math.min(1, v))); }, []); const play = useCallback(() => setIsPlaying(true), []); const pause = useCallback(() => setIsPlaying(false), []); const setSpeed = useCallback((factor: number) => { setAnimationSpeed(factor); }, []); /** epochSec = SDK 내부 초 단위 시간 */ const seekTo = useCallback((epochSec: number) => { for (const layer of layerInstancesRef.current.values()) { layer.setAnimationTime(epochSec); } setCurrentTime(new Date(epochSec * 1000)); // SDK CustomLayerInterface.render() 가 호출되어야 타일이 실제 갱신됨 // 여러 프레임에 걸쳐 repaint 트리거 if (map) { let count = 0; const kick = () => { map.triggerRepaint(); if (++count < 6) requestAnimationFrame(kick); }; kick(); } }, [map]); const activeLayerId = (Object.keys(enabled) as WeatherLayerId[]).find((k) => enabled[k]) ?? null; return { enabled, activeLayerId, opacity, isPlaying, animationSpeed, currentTime, startTime, endTime, steps, isReady, toggleLayer, setOpacity, play, pause, setSpeed, seekTo, }; }