- 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>
330 lines
12 KiB
TypeScript
330 lines
12 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 { 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>
|
||
);
|
||
}
|