kcg-monitoring/frontend/src/components/common/SensorChart.tsx
htlee 6c54500c70 feat: 센서 그래프 실데이터 + 선박 모달 UI 개선 + KST/UTC 라디오
- SensorChart: 백엔드 실데이터(지진/기압) + 동적 x축 시간 + 히스토리 10M/30M/1H/3H/6H
- LiveControls: KST/UTC 토글 → 라디오 버튼 그룹
- ShipLayer: 모달 고정크기(300px), 드래그 가능, S&P Global 다중사진 슬라이드
- 선박 모달 CSS 통일 (태그 스타일, 2컬럼 그리드, 긴 값 단독행)
- 센서 API: hours→min 파라미터 (기본 2880=48h), 인증 예외 처리
- useIranData/useKoreaData: 센서 10분 polling + 선박 60분 초기/6분 incremental merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:23:45 +09:00

209 lines
7.3 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { SeismicDto, PressureDto } from '../../services/sensorApi';
interface Props {
seismicData: SeismicDto[];
pressureData: PressureDto[];
currentTime: number;
historyMinutes: number;
}
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 aggregateSeismic(
data: SeismicDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; value: number }[] {
const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT;
const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({
time: rangeStart + (i + 0.5) * bucketSize,
value: 0,
}));
for (const ev of data) {
if (ev.timestamp < rangeStart || ev.timestamp > rangeEnd) continue;
const idx = Math.min(Math.floor((ev.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1);
buckets[idx].value = Math.max(buckets[idx].value, ev.magnitude * 10);
}
return buckets;
}
function aggregatePressure(
data: PressureDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; value: number }[] {
const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT;
const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({
time: rangeStart + (i + 0.5) * bucketSize,
values: [] as number[],
}));
for (const r of data) {
if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue;
const idx = Math.min(Math.floor((r.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1);
buckets[idx].values.push(r.pressureHpa);
}
return buckets.map(b => ({
time: b.time,
value: b.values.length > 0 ? b.values.reduce((a, c) => a + c, 0) / b.values.length : 0,
}));
}
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) };
});
}
export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) {
const { t } = useTranslation();
const totalMinutes = historyMinutes * 8;
const rangeStart = currentTime - totalMinutes * MINUTE;
const rangeEnd = currentTime;
const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]);
const seismicChart = useMemo(
() => aggregateSeismic(seismicData, rangeStart, rangeEnd),
[seismicData, rangeStart, rangeEnd],
);
const pressureChart = useMemo(
() => aggregatePressure(pressureData, rangeStart, rangeEnd),
[pressureData, rangeStart, rangeEnd],
);
const noiseChart = useMemo(
() => generateDemoData(rangeStart, rangeEnd, 45, 30),
[rangeStart, rangeEnd],
);
const radiationChart = useMemo(
() => generateDemoData(rangeStart, rangeEnd, 0.08, 0.06),
[rangeStart, rangeEnd],
);
const commonXAxis = {
dataKey: 'time' as const,
type: 'number' as const,
domain: [rangeStart, rangeEnd] as [number, number],
ticks,
tickFormatter: formatTickTime,
tick: { fontSize: 9, fill: '#888' },
};
return (
<div className="sensor-chart">
<h3>{t('sensor.title')}</h3>
<div className="chart-grid">
<div className="chart-item">
<h4>{t('sensor.seismicActivity')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={seismicChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'Magnitude×10']}
/>
<Line type="monotone" dataKey="value" stroke="#ef4444" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={pressureChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[990, 1030]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'hPa']}
/>
<Line type="monotone" dataKey="value" stroke="#3b82f6" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>
{t('sensor.noiseLevelDb')}{' '}
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={noiseChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'dB']}
/>
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>
{t('sensor.radiationUsv')}{' '}
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={radiationChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(3), 'μSv/h']}
/>
<Line type="monotone" dataKey="value" stroke="#22c55e" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}