- 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>
209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|