import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Cell, } from 'recharts'; import type { TooltipProps } from 'recharts'; import type { SeismicDto, PressureDto } from '../../services/sensorApi'; interface Props { seismicData: SeismicDto[]; pressureData: PressureDto[]; currentTime: number; historyMinutes: number; onSeismicClick?: (lat: number, lng: number, magnitude: number, place: string) => void; } const MINUTE = 60_000; const BUCKET_COUNT = 48; function formatTickTime(epoch: number): string { const d = new Date(epoch); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function buildTicks(currentTime: number, historyMinutes: number): number[] { const interval = historyMinutes * MINUTE; const ticks: number[] = []; for (let i = 8; i >= 0; i--) { ticks.push(currentTime - i * interval); } return ticks; } /** * 지진 데이터: 버킷이 아닌 실제 이벤트 포인트로 변환 */ function prepareSeismicPoints( data: SeismicDto[], rangeStart: number, rangeEnd: number, ): { time: number; magnitude: number; place: string; lat: number; lng: number }[] { return data .filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd) .map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place, lat: ev.lat, lng: ev.lng })); } /** * 기압 데이터: 원본 시간 포인트를 관측소별 컬럼으로 통합 * 동일 timestamp의 관측소 데이터를 하나의 row로 합침 * 버킷 없이 원본 그대로 사용하여 히스토리 범위와 무관하게 일관된 그래프 표시 */ function preparePressureMerged( data: PressureDto[], rangeStart: number, rangeEnd: number, ): { time: number; [station: string]: number | null }[] { // 범위 내 데이터만 필터 const filtered = data.filter(r => r.timestamp >= rangeStart && r.timestamp <= rangeEnd); const stations = new Set(); for (const r of filtered) stations.add(r.station); // timestamp별 그룹핑 (해수면 보정 적용) const timeMap = new Map>(); for (const r of filtered) { if (!timeMap.has(r.timestamp)) timeMap.set(r.timestamp, {}); const alt = STATION_ALTITUDE[r.station] ?? 0; timeMap.get(r.timestamp)![r.station] = toSeaLevelPressure(r.pressureHpa, alt); } // 시간순 정렬하여 row 배열 생성 const stationList = Array.from(stations); const times = Array.from(timeMap.keys()).sort((a, b) => a - b); return times.map(t => { const vals = timeMap.get(t)!; const row: Record = { time: t }; for (const s of stationList) { row[s] = vals[s] ?? null; } return row as { time: number; [station: string]: number | null }; }); } function generateDemoData( rangeStart: number, rangeEnd: number, baseValue: number, variance: number, ): { time: number; value: number }[] { const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT; return Array.from({ length: BUCKET_COUNT }, (_, i) => { const t = rangeStart + (i + 0.5) * bucketSize; const seed = Math.sin(t / 100_000) * 10000; const noise = (seed - Math.floor(seed)) * 2 - 1; return { time: t, value: Math.max(0, baseValue + noise * variance) }; }); } const STATION_COLORS: Record = { 'tehran': '#ef4444', 'isfahan': '#f97316', 'bandar-abbas': '#3b82f6', 'shiraz': '#22c55e', 'tabriz': '#a855f7', }; // 관측소 고도(m) — 해수면 기압 보정용 const STATION_ALTITUDE: Record = { 'tehran': 1190, 'isfahan': 1590, 'bandar-abbas': 10, 'shiraz': 1486, 'tabriz': 1352, }; // 기압 해수면 보정 (barometric formula 근사) // SLP ≈ station_pressure × (1 + 0.0065 × altitude / (temperature + 0.0065 × altitude + 273.15))^5.257 // 간단 근사: SLP ≈ station_pressure + altitude / 8.3 (1 hPa per 8.3m at low altitude) function toSeaLevelPressure(stationHpa: number, altitudeM: number): number { return stationHpa + altitudeM / 8.3; } const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b']; const TOOLTIP_STYLE = { contentStyle: { background: '#1a1a2e', border: '1px solid #333', fontSize: 10, color: '#e0e0e0', padding: '4px 8px' }, itemStyle: { color: '#e0e0e0' }, labelStyle: { color: '#aaa' }, wrapperStyle: { zIndex: 10 }, position: { y: -10 } as { y: number }, }; function formatTooltipTime(epoch: number): string { const d = new Date(epoch); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} KST`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function SeismicTooltip({ active, payload }: TooltipProps) { if (!active || !payload?.length) return null; const point = payload[0]?.payload as { time: number; magnitude: number; place: string; lat: number; lng: number } | undefined; if (!point) return null; return (
{formatTooltipTime(point.time)}
M{point.magnitude.toFixed(1)} {point.place}
{point.lat.toFixed(3)}N, {point.lng.toFixed(3)}E — click to focus
); } function getMagnitudeColor(mag: number): string { if (mag < 2.5) return MAGNITUDE_COLORS[0]; if (mag < 3.5) return MAGNITUDE_COLORS[1]; if (mag < 4.5) return MAGNITUDE_COLORS[2]; if (mag < 5.5) return MAGNITUDE_COLORS[3]; return MAGNITUDE_COLORS[4]; } export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes, onSeismicClick }: Props) { const { t } = useTranslation(); const totalMinutes = historyMinutes * 8; const rangeStart = currentTime - totalMinutes * MINUTE; const rangeEnd = currentTime; // 데이터 수집 범위: 표시 범위보다 충분히 과거 (선이 y축에서 시작되도록) // 기압 데이터는 1시간 간격이므로 최소 60분 + 1칸 여유 const dataStart = rangeStart - Math.max(historyMinutes, 60) * MINUTE; const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]); const seismicPoints = useMemo( () => prepareSeismicPoints(seismicData, dataStart, rangeEnd), [seismicData, dataStart, rangeEnd], ); const pressureMerged = useMemo( () => preparePressureMerged(pressureData, dataStart, rangeEnd), [pressureData, dataStart, rangeEnd], ); const pressureStations = useMemo(() => { const stations = new Set(); for (const r of pressureData) stations.add(r.station); return Array.from(stations); }, [pressureData]); const noiseChart = useMemo( () => generateDemoData(dataStart, rangeEnd, 45, 30), [dataStart, rangeEnd], ); const radiationChart = useMemo( () => generateDemoData(dataStart, rangeEnd, 0.08, 0.06), [dataStart, rangeEnd], ); const commonXAxis = { type: 'number' as const, domain: [rangeStart, rangeEnd] as [number, number], ticks, tickFormatter: formatTickTime, tick: { fontSize: 9, fill: '#888' }, }; // 지진 y축: 데이터가 있으면 0~max+1, 없으면 0~6 const maxMag = seismicPoints.length > 0 ? Math.max(...seismicPoints.map(p => p.magnitude)) : 0; const seismicYMax = Math.max(maxMag + 1, 6); return (

{t('sensor.title')}

{/* 지진파: Scatter (이벤트 점) */}

{t('sensor.seismicActivity')} {seismicPoints.length > 0 && ( ({seismicPoints.length} events) )}

} {...TOOLTIP_STYLE} /> { if (entry && onSeismicClick) onSeismicClick(entry.lat, entry.lng, entry.magnitude, entry.place); }} > {seismicPoints.map((p, i) => ( ))}
{/* 기압: 관측소별 개별 라인 (해수면 보정) */}

{t('sensor.airPressureHpa')} (SLP)

[v != null ? `${v.toFixed(1)} hPa` : '-', name]} /> {pressureStations.map(station => ( ))}

{t('sensor.noiseLevelDb')}{' '} (DEMO)

[v.toFixed(1), 'dB']} />

{t('sensor.radiationUsv')}{' '} (DEMO)

[v.toFixed(3), 'μSv/h']} />
); }