416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
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<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,
|
|
};
|
|
}
|