kcg-monitoring/frontend/src/components/common/SensorChart.tsx
htlee 7fa4e2bfb1 feat: 센서 그래프 개선 + 지진 마커 + 시설 아이콘 정렬 + SSH 재시도 v2
- SensorChart: 히스토리 1H/2H/3H/6H, 기압 SLP 보정, 데이터 범위 확장(y축 시작)
- SensorChart Tooltip: KST 시간 포맷, 위치 상단 고정, 스타일 통일
- 지진 포인트 클릭 → 지도 flyTo + SeismicMarker 진도별 펄스 원형 표시
- SatelliteMap flyTo 지원 추가
- OilFacilityLayer: planned ring SVG 내부로 이동 (아이콘 중심 정렬 수정)
- 밝은 테마 text-shadow CSS 변수 분리 (dark/light)
- deploy.yml: SSH SCP+실행 각 3회 재시도 (kex_exchange 거부 대응)

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

330 lines
12 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,
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<string>();
for (const r of filtered) stations.add(r.station);
// timestamp별 그룹핑 (해수면 보정 적용)
const timeMap = new Map<number, Record<string, number>>();
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<string, number | null> = { 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<string, string> = {
'tehran': '#ef4444',
'isfahan': '#f97316',
'bandar-abbas': '#3b82f6',
'shiraz': '#22c55e',
'tabriz': '#a855f7',
};
// 관측소 고도(m) — 해수면 기압 보정용
const STATION_ALTITUDE: Record<string, number> = {
'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<any, any>) {
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 (
<div style={{ background: '#1a1a2e', border: '1px solid #333', padding: '4px 8px', borderRadius: 4, fontSize: 10, color: '#e0e0e0' }}>
<div style={{ color: '#aaa', marginBottom: 2 }}>{formatTooltipTime(point.time)}</div>
<div><span style={{ color: getMagnitudeColor(point.magnitude), fontWeight: 700 }}>M{point.magnitude.toFixed(1)}</span> {point.place}</div>
<div style={{ color: '#888', fontSize: 9 }}>{point.lat.toFixed(3)}N, {point.lng.toFixed(3)}E click to focus</div>
</div>
);
}
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<string>();
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 (
<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 content={<SeismicTooltip />} {...TOOLTIP_STYLE} />
<Scatter
data={seismicPoints}
shape="circle"
cursor="pointer"
onClick={(entry) => {
if (entry && onSeismicClick) onSeismicClick(entry.lat, entry.lng, entry.magnitude, entry.place);
}}
>
{seismicPoints.map((p, i) => (
<Cell key={i} fill={getMagnitudeColor(p.magnitude)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* 기압: 관측소별 개별 라인 (해수면 보정) */}
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')} <span className="text-[8px] text-kcg-muted">(SLP)</span></h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={pressureMerged}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={['auto', 'auto']} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
formatter={(v: number, name: string) => [v != null ? `${v.toFixed(1)} hPa` : '-', name]}
/>
{pressureStations.map(station => (
<Line
key={station}
dataKey={station}
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
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
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
{...TOOLTIP_STYLE}
labelFormatter={formatTooltipTime}
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>
);
}