kcg-monitoring/frontend/src/components/common/SensorChart.tsx
htlee e765cc6636 feat: 센서 그래프 개선 + 한국 선박 지도 강조/인터랙션
- SensorChart: 지진 ScatterChart(진도별 색상/크기) + 기압 관측소별 개별 라인
- 한국 선박 현황 ON/OFF 토글 → 지도 강조 링/라벨 표시 (기본 ON)
- 우측 패널 한국 선박 목록: hover 시 지도 강조 링, 클릭 시 모달 호출
- ShipLayer: hoveredMmsi/focusMmsi props, 외부 모달 트리거

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

262 lines
8.9 KiB
TypeScript

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 { 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 prepareSeismicPoints(
data: SeismicDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; magnitude: number; place: string }[] {
return data
.filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd)
.map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place }));
}
/**
* 기압 데이터: 관측소별 시간순 포인트 (버킷 없이 원본 사용)
* connectNulls 대신, 데이터가 있는 포인트만 연결
*/
function preparePressureByStation(
data: PressureDto[],
rangeStart: number,
rangeEnd: number,
): Record<string, { time: number; value: number }[]> {
const byStation: Record<string, { time: number; value: number }[]> = {};
for (const r of data) {
if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue;
if (!byStation[r.station]) byStation[r.station] = [];
byStation[r.station].push({ time: r.timestamp, value: r.pressureHpa });
}
// 시간순 정렬
for (const arr of Object.values(byStation)) {
arr.sort((a, b) => a.time - b.time);
}
return byStation;
}
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<string, string> = {
'tehran': '#ef4444',
'isfahan': '#f97316',
'bandar-abbas': '#3b82f6',
'shiraz': '#22c55e',
'tabriz': '#a855f7',
};
const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b'];
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 }: 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 seismicPoints = useMemo(
() => prepareSeismicPoints(seismicData, rangeStart, rangeEnd),
[seismicData, rangeStart, rangeEnd],
);
const pressureByStation = useMemo(
() => preparePressureByStation(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 = {
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 (
<div className="sensor-chart">
<h3>{t('sensor.title')}</h3>
<div className="chart-grid">
{/* 지진파: Scatter (이벤트 점) */}
<div className="chart-item">
<h4>
{t('sensor.seismicActivity')}
{seismicPoints.length > 0 && (
<span className="text-[9px] text-kcg-muted ml-1">
({seismicPoints.length} events)
</span>
)}
</h4>
<ResponsiveContainer width="100%" height={80}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis dataKey="magnitude" domain={[0, seismicYMax]} tick={{ fontSize: 10, fill: '#888' }} name="M" />
<ZAxis dataKey="magnitude" range={[30, 200]} name="Size" />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number, name: string) => {
if (name === 'M') return [`M${v.toFixed(1)}`, 'Magnitude'];
return [v, name];
}}
/>
<Scatter data={seismicPoints} shape="circle">
{seismicPoints.map((p, i) => (
<Cell key={i} fill={getMagnitudeColor(p.magnitude)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* 기압: 관측소별 개별 라인 */}
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={['auto', 'auto']} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number, name: string) => [`${v.toFixed(1)} hPa`, name]}
/>
{Object.entries(pressureByStation).map(([station, points]) => (
<Line
key={station}
data={points}
dataKey="value"
name={station}
stroke={STATION_COLORS[station] || '#888'}
dot={false}
strokeWidth={1.2}
connectNulls
isAnimationActive={false}
/>
))}
</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 dataKey="time" {...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 dataKey="time" {...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>
);
}