- SensorChart: 지진 ScatterChart(진도별 색상/크기) + 기압 관측소별 개별 라인 - 한국 선박 현황 ON/OFF 토글 → 지도 강조 링/라벨 표시 (기본 ON) - 우측 패널 한국 선박 목록: hover 시 지도 강조 링, 클릭 시 모달 호출 - ShipLayer: hoveredMmsi/focusMmsi props, 외부 모달 트리거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
262 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|