gc-wing/apps/web/src/features/weatherOverlay/useWeatherOverlay.ts

416 lines
13 KiB
TypeScript
Raw Normal View 히스토리

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,
};
}