feat: 센서 그래프 실데이터 + 선박 모달 UI 개선 + KST/UTC 라디오

- 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>
This commit is contained in:
htlee 2026-03-18 09:23:45 +09:00
부모 7fabe16f4f
커밋 6c54500c70
14개의 변경된 파일814개의 추가작업 그리고 197개의 파일을 삭제

파일 보기

@ -83,5 +83,7 @@
] ]
} }
] ]
} },
"deny": [],
"allow": []
} }

파일 보기

@ -21,13 +21,14 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String JWT_COOKIE_NAME = "kcg_token"; private static final String JWT_COOKIE_NAME = "kcg_token";
private static final String AUTH_PATH_PREFIX = "/api/auth/"; private static final String AUTH_PATH_PREFIX = "/api/auth/";
private static final String SENSOR_PATH_PREFIX = "/api/sensor/";
private final JwtProvider jwtProvider; private final JwtProvider jwtProvider;
@Override @Override
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI(); String path = request.getRequestURI();
return path.startsWith(AUTH_PATH_PREFIX); return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX);
} }
@Override @Override

파일 보기

@ -18,11 +18,12 @@ public class SensorController {
/** /**
* 지진 이벤트 조회 (USGS 수집 데이터) * 지진 이벤트 조회 (USGS 수집 데이터)
* @param min 조회 범위 ( 단위, 기본 2880 = 48시간)
*/ */
@GetMapping("/seismic") @GetMapping("/seismic")
public Map<String, Object> getSeismic( public Map<String, Object> getSeismic(
@RequestParam(defaultValue = "24") int hours) { @RequestParam(defaultValue = "2880") int min) {
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); Instant since = Instant.now().minus(min, ChronoUnit.MINUTES);
List<SensorDto.SeismicDto> data = seismicRepo List<SensorDto.SeismicDto> data = seismicRepo
.findByEventTimeAfterOrderByEventTimeDesc(since) .findByEventTimeAfterOrderByEventTimeDesc(since)
.stream().map(SensorDto.SeismicDto::from).toList(); .stream().map(SensorDto.SeismicDto::from).toList();
@ -31,11 +32,12 @@ public class SensorController {
/** /**
* 기압 데이터 조회 (Open-Meteo 수집 데이터) * 기압 데이터 조회 (Open-Meteo 수집 데이터)
* @param min 조회 범위 ( 단위, 기본 2880 = 48시간)
*/ */
@GetMapping("/pressure") @GetMapping("/pressure")
public Map<String, Object> getPressure( public Map<String, Object> getPressure(
@RequestParam(defaultValue = "24") int hours) { @RequestParam(defaultValue = "2880") int min) {
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); Instant since = Instant.now().minus(min, ChronoUnit.MINUTES);
List<SensorDto.PressureDto> data = pressureRepo List<SensorDto.PressureDto> data = pressureRepo
.findByReadingTimeAfterOrderByReadingTimeAsc(since) .findByReadingTimeAfterOrderByReadingTimeAsc(since)
.stream().map(SensorDto.PressureDto::from).toList(); .stream().map(SensorDto.PressureDto::from).toList();

파일 보기

@ -1137,6 +1137,157 @@
opacity: 0.7; opacity: 0.7;
} }
/* ═══ Ship popup modal ═══ */
.vessel-photo-frame {
height: 160px;
}
.ship-popup-body {
width: 300px;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.4;
}
.ship-popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 4px 4px 0 0;
margin: -10px -10px 8px -10px;
color: #fff;
}
.ship-popup-name {
flex: 1;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ship-popup-navy-badge {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
color: #000;
line-height: 1;
}
/* Type tags row */
.ship-popup-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-bottom: 6px;
margin-bottom: 6px;
border-bottom: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.15));
}
.ship-tag {
display: inline-block;
padding: 2px 7px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
.ship-tag-primary {
color: #fff;
}
.ship-tag-secondary {
background: var(--kcg-border, rgba(100, 116, 139, 0.3));
color: var(--kcg-text-secondary, #94a3b8);
}
.ship-tag-dim {
background: var(--kcg-hover, rgba(100, 116, 139, 0.1));
color: var(--kcg-dim, #64748b);
}
/* Data grid */
.ship-popup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 12px;
}
.ship-popup-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
border-bottom: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.08));
overflow: hidden;
min-height: 18px;
}
.ship-popup-row:nth-last-child(1),
.ship-popup-row:nth-last-child(2) {
border-bottom: none;
}
/* Full-width rows for long values (status, destination, ETA) */
.ship-popup-full-row {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
border-top: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.08));
}
.ship-popup-full-row .ship-popup-value {
text-align: right;
min-width: 0;
}
.ship-popup-label {
color: var(--kcg-muted, #64748b);
font-size: 10px;
flex-shrink: 0;
margin-right: 4px;
}
.ship-popup-value {
font-size: 10px;
color: var(--kcg-text, #e2e8f0);
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Footer */
.ship-popup-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.15));
}
.ship-popup-timestamp {
font-size: 9px;
color: var(--kcg-dim, #64748b);
}
.ship-popup-link {
font-size: 10px;
color: var(--kcg-accent, #3b82f6);
text-decoration: none;
}
.ship-popup-link:hover {
text-decoration: underline;
}
/* Footer / Controls */ /* Footer / Controls */
.app-footer { .app-footer {
background: var(--bg-card); background: var(--bg-card);
@ -1890,6 +2041,39 @@
color: var(--kcg-danger); color: var(--kcg-danger);
} }
.tz-radio-group {
display: inline-flex;
border: 1px solid var(--kcg-border);
border-radius: 3px;
overflow: hidden;
}
.tz-radio-btn {
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
font-family: 'Courier New', monospace;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
line-height: 1.4;
}
.tz-radio-btn + .tz-radio-btn {
border-left: 1px solid var(--kcg-border);
}
.tz-radio-btn.active {
background: rgba(239, 68, 68, 0.15);
color: var(--kcg-danger);
}
.tz-radio-btn:hover:not(.active) {
color: var(--text-primary);
}
/* Live mode accent on header border */ /* Live mode accent on header border */
.app-live .app-header { .app-live .app-header {
border-bottom-color: rgba(239, 68, 68, 0.3); border-bottom-color: rgba(239, 68, 68, 0.3);

파일 보기

@ -119,6 +119,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}, []); }, []);
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const replay = useReplay(); const replay = useReplay();
const monitor = useMonitor(); const monitor = useMonitor();
@ -132,8 +133,6 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
// Unified time values based on current mode // Unified time values based on current mode
const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime; const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime;
const startTime = appMode === 'live' ? monitor.startTime : replay.state.startTime;
const endTime = appMode === 'live' ? monitor.endTime : replay.state.endTime;
// Iran data hook // Iran data hook
const iranData = useIranData({ const iranData = useIranData({
@ -430,10 +429,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{layers.sensorCharts && ( {layers.sensorCharts && (
<section className="charts-panel"> <section className="charts-panel">
<SensorChart <SensorChart
data={iranData.sensorData} seismicData={iranData.seismicData}
pressureData={iranData.pressureData}
currentTime={currentTime} currentTime={currentTime}
startTime={startTime} historyMinutes={monitor.state.historyMinutes}
endTime={endTime}
/> />
</section> </section>
)} )}
@ -447,6 +446,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
aircraftCount={iranData.aircraft.length} aircraftCount={iranData.aircraft.length}
shipCount={iranData.ships.length} shipCount={iranData.ships.length}
satelliteCount={iranData.satPositions.length} satelliteCount={iranData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/> />
) : ( ) : (
<> <>
@ -550,6 +551,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
aircraftCount={koreaData.aircraft.length} aircraftCount={koreaData.aircraft.length}
shipCount={koreaData.ships.length} shipCount={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length} satelliteCount={koreaData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/> />
) : ( ) : (
<> <>

파일 보기

@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useState } from 'react';
interface Props { interface Props {
currentTime: number; currentTime: number;
@ -8,35 +7,35 @@ interface Props {
aircraftCount: number; aircraftCount: number;
shipCount: number; shipCount: number;
satelliteCount: number; satelliteCount: number;
timeZone: 'KST' | 'UTC';
onTimeZoneChange: (tz: 'KST' | 'UTC') => void;
} }
const HISTORY_PRESETS = [ const HISTORY_PRESETS = [
{ label: '10M', minutes: 10 },
{ label: '30M', minutes: 30 }, { label: '30M', minutes: 30 },
{ label: '1H', minutes: 60 }, { label: '1H', minutes: 60 },
{ label: '3H', minutes: 180 }, { label: '3H', minutes: 180 },
{ label: '6H', minutes: 360 }, { label: '6H', minutes: 360 },
{ label: '12H', minutes: 720 },
{ label: '24H', minutes: 1440 },
]; ];
function formatTime(epoch: number, tz: 'KST' | 'UTC'): string { function formatTime(epoch: number, tz: 'KST' | 'UTC'): string {
const d = new Date(epoch); const d = new Date(epoch);
const pad = (n: number) => String(n).padStart(2, '0');
if (tz === 'UTC') { if (tz === 'UTC') {
const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`;
} }
// KST: 브라우저 로컬 타임존 사용 (한국 환경에서 자동 KST) return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
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())}:${pad(d.getSeconds())} KST`;
} }
export function LiveControls({ export function LiveControls({
currentTime, currentTime,
historyMinutes, historyMinutes,
onHistoryChange, onHistoryChange,
timeZone,
onTimeZoneChange,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
return ( return (
<div className="live-controls"> <div className="live-controls">
@ -47,24 +46,18 @@ export function LiveControls({
<div className="live-clock" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div className="live-clock" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>{formatTime(currentTime, timeZone)}</span> <span>{formatTime(currentTime, timeZone)}</span>
<div className="tz-radio-group">
{(['KST', 'UTC'] as const).map(tz => (
<button <button
key={tz}
type="button" type="button"
onClick={() => setTimeZone(prev => prev === 'KST' ? 'UTC' : 'KST')} className={`tz-radio-btn ${timeZone === tz ? 'active' : ''}`}
style={{ onClick={() => onTimeZoneChange(tz)}
background: 'none',
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
color: 'var(--kcg-text-secondary, #94a3b8)',
borderRadius: '3px',
padding: '1px 5px',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'monospace',
lineHeight: 1.2,
}}
title="KST/UTC 전환"
> >
{timeZone} {tz}
</button> </button>
))}
</div>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />

파일 보기

@ -8,33 +8,122 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
ReferenceLine,
} from 'recharts'; } from 'recharts';
import type { SensorLog } from '../../types'; import type { SeismicDto, PressureDto } from '../../services/sensorApi';
interface Props { interface Props {
data: SensorLog[]; seismicData: SeismicDto[];
pressureData: PressureDto[];
currentTime: number; currentTime: number;
startTime: number; historyMinutes: number;
endTime: number;
} }
export function SensorChart({ data, currentTime, startTime }: Props) { 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 { t } = useTranslation();
const visibleData = useMemo( const totalMinutes = historyMinutes * 8;
() => data.filter(d => d.timestamp <= currentTime), const rangeStart = currentTime - totalMinutes * MINUTE;
[data, currentTime], 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 chartData = useMemo( const commonXAxis = {
() => dataKey: 'time' as const,
visibleData.map(d => ({ type: 'number' as const,
...d, domain: [rangeStart, rangeEnd] as [number, number],
time: formatHour(d.timestamp, startTime), ticks,
})), tickFormatter: formatTickTime,
[visibleData, startTime], tick: { fontSize: 9, fill: '#888' },
); };
return ( return (
<div className="sensor-chart"> <div className="sensor-chart">
@ -43,13 +132,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<div className="chart-item"> <div className="chart-item">
<h4>{t('sensor.seismicActivity')}</h4> <h4>{t('sensor.seismicActivity')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={seismicChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} /> <XAxis {...commonXAxis} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} /> <YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} /> <Tooltip
<ReferenceLine x={formatHour(currentTime, startTime)} stroke="#fff" strokeDasharray="3 3" /> contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
<Line type="monotone" dataKey="seismic" stroke="#ef4444" dot={false} strokeWidth={1.5} /> labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'Magnitude×10']}
/>
<Line type="monotone" dataKey="value" stroke="#ef4444" dot={false} strokeWidth={1.5} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -57,12 +149,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<div className="chart-item"> <div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4> <h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={pressureChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} /> <XAxis {...commonXAxis} />
<YAxis domain={[990, 1020]} tick={{ fontSize: 10, fill: '#888' }} /> <YAxis domain={[990, 1030]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} /> <Tooltip
<Line type="monotone" dataKey="airPressure" stroke="#3b82f6" dot={false} strokeWidth={1.5} /> 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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -73,12 +169,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<span className="chart-demo-label">(DEMO)</span> <span className="chart-demo-label">(DEMO)</span>
</h4> </h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={noiseChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} /> <XAxis {...commonXAxis} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} /> <YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} /> <Tooltip
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} /> 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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -89,12 +189,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<span className="chart-demo-label">(DEMO)</span> <span className="chart-demo-label">(DEMO)</span>
</h4> </h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={radiationChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} /> <XAxis {...commonXAxis} />
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} /> <YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} /> <Tooltip
<Line type="monotone" dataKey="radiationLevel" stroke="#22c55e" dot={false} strokeWidth={1.5} /> 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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -102,10 +206,3 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div> </div>
); );
} }
function formatHour(timestamp: number, startTime: number): string {
const hours = (timestamp - startTime) / 3600_000;
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h}:${m.toString().padStart(2, '0')}`;
}

파일 보기

@ -1,4 +1,4 @@
import { memo, useMemo, useState, useEffect } from 'react'; import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../../types'; import type { Ship, ShipCategory } from '../../types';
@ -135,27 +135,63 @@ interface VesselPhotoProps {
mmsi: string; mmsi: string;
imo?: string; imo?: string;
shipImagePath?: string | null; shipImagePath?: string | null;
shipImageCount?: number;
} }
function toHighRes(path: string): string { function toHighRes(path: string): string {
return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1'); return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1');
} }
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { /**
* S&P Global N번째 URL
* : /shipimg/.../photo_1.jpg photo_2.jpg (), photo_3.jpg, ...
* shipImageCount가 N이면 _1 ~ _N ( _2 )
*/
function buildSpgUrls(basePath: string, count: number): string[] {
const urls: string[] = [];
// 항상 고화질(_2) 우선, count만큼 생성
for (let i = 1; i <= Math.max(count, 1); i++) {
// basePath 예: /shipimg/.../1234_1.jpg
// _1을 _i로 교체 후 고화질로 변환
const indexed = basePath.replace(/_1\.(jpg|jpeg|png)$/i, `_${i}.$1`);
urls.push(toHighRes(indexed));
}
return urls;
}
function VesselPhoto({ mmsi, shipImagePath, shipImageCount }: VesselPhotoProps) {
const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
const hasSPGlobal = !!shipImagePath; const hasSPGlobal = !!shipImagePath;
const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic'; // 항상 S&P Global 우선 (모달 열릴 때마다 리셋)
const [activeTab, setActiveTab] = useState<PhotoSource>(defaultTab); const [activeTab, setActiveTab] = useState<PhotoSource>(hasSPGlobal ? 'spglobal' : 'marinetraffic');
const [spgSlideIdx, setSpgSlideIdx] = useState(0);
const [spgErrors, setSpgErrors] = useState<Set<number>>(new Set());
// S&P Global image error state // 모달이 다른 선박으로 변경될 때 탭/슬라이드 리셋
const [spgError, setSpgError] = useState(false); useEffect(() => {
setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic');
setSpgSlideIdx(0);
setSpgErrors(new Set());
}, [mmsi, hasSPGlobal]);
// S&P Global slide URLs
const spgUrls = useMemo(
() => shipImagePath ? buildSpgUrls(shipImagePath, shipImageCount ?? 1) : [],
[shipImagePath, shipImageCount],
);
const validSpgCount = spgUrls.length;
// MarineTraffic image state (lazy loaded) // MarineTraffic image state (lazy loaded)
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => { const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
}); });
useEffect(() => {
// 새 선박이면 캐시 확인
setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined);
}, [mmsi]);
useEffect(() => { useEffect(() => {
if (activeTab !== 'marinetraffic') return; if (activeTab !== 'marinetraffic') return;
if (mtPhoto !== undefined) return; if (mtPhoto !== undefined) return;
@ -170,8 +206,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
let currentUrl: string | null = null; let currentUrl: string | null = null;
if (localUrl) { if (localUrl) {
currentUrl = localUrl; currentUrl = localUrl;
} else if (activeTab === 'spglobal' && shipImagePath && !spgError) { } else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) {
currentUrl = toHighRes(shipImagePath); currentUrl = spgUrls[spgSlideIdx];
} else if (activeTab === 'marinetraffic' && mtPhoto) { } else if (activeTab === 'marinetraffic' && mtPhoto) {
currentUrl = mtPhoto.url; currentUrl = mtPhoto.url;
} }
@ -180,15 +216,18 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
if (localUrl) { if (localUrl) {
return ( return (
<div className="mb-1.5"> <div className="mb-1.5">
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
<img src={localUrl} alt="Vessel" <img src={localUrl} alt="Vessel"
className="w-full rounded block" className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/> />
</div> </div>
</div>
); );
} }
const noPhoto = (!hasSPGlobal || spgError) && mtPhoto === null; const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i));
const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null;
return ( return (
<div className="mb-1.5"> <div className="mb-1.5">
@ -212,40 +251,67 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
MarineTraffic MarineTraffic
</div> </div>
</div> </div>
{/* 고정 높이 사진 영역 */}
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
{currentUrl ? ( {currentUrl ? (
<img src={currentUrl} alt="Vessel" <img
className="w-full rounded block" key={currentUrl}
onError={(e) => { src={currentUrl}
const el = e.target as HTMLImageElement; alt="Vessel"
className="w-full h-full object-contain"
onError={() => {
if (activeTab === 'spglobal') { if (activeTab === 'spglobal') {
setSpgError(true); setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
el.style.display = 'none';
} else {
el.style.display = 'none';
} }
}} }}
/> />
) : noPhoto ? ( ) : noPhoto ? (
<div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded"> <div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available No photo available
</div> </div>
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
Loading...
</div>
) : ( ) : (
activeTab === 'marinetraffic' && mtPhoto === undefined <div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
? <div className="text-center py-3 text-kcg-dim text-[10px]">Loading...</div>
: <div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded">
No photo available No photo available
</div> </div>
)} )}
{/* S&P Global 슬라이드 네비게이션 */}
{activeTab === 'spglobal' && validSpgCount > 1 && (
<>
<button
type="button"
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i - 1 + validSpgCount) % validSpgCount); }}
>
&lt;
</button>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i + 1) % validSpgCount); }}
>
&gt;
</button>
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1">
{spgUrls.map((_, i) => (
<span
key={i}
className={`w-1.5 h-1.5 rounded-full ${i === spgSlideIdx ? 'bg-white' : 'bg-white/40'}`}
/>
))}
</div>
</>
)}
</div>
</div> </div>
); );
} }
function formatCoord(lat: number, lng: number): string {
const latDir = lat >= 0 ? 'N' : 'S';
const lngDir = lng >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`;
}
// Create triangle SDF image for MapLibre symbol layer // Create triangle SDF image for MapLibre symbol layer
const TRIANGLE_SIZE = 64; const TRIANGLE_SIZE = 64;
@ -422,63 +488,168 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClo
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined; const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
// Draggable popup
const popupRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const dragOffset = useRef({ x: 0, y: 0 });
const onMouseDown = useCallback((e: React.MouseEvent) => {
// Only drag from header area
const target = e.target as HTMLElement;
if (!target.closest('.ship-popup-header')) return;
e.preventDefault();
dragging.current = true;
const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null;
if (!popupEl) return;
const rect = popupEl.getBoundingClientRect();
dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
popupEl.style.transition = 'none';
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null;
if (!popupEl) return;
// Switch to fixed positioning for free drag
popupEl.style.transform = 'none';
popupEl.style.position = 'fixed';
popupEl.style.left = `${e.clientX - dragOffset.current.x}px`;
popupEl.style.top = `${e.clientY - dragOffset.current.y}px`;
};
const onMouseUp = () => { dragging.current = false; };
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, []);
return ( return (
<Popup longitude={ship.lng} latitude={ship.lat} <Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false} onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup"> anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="min-w-[280px] max-w-[340px] font-mono text-xs"> <div ref={popupRef} className="ship-popup-body" onMouseDown={onMouseDown}>
{/* Header — draggable handle */}
<div <div
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2" className="ship-popup-header"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }} style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0', cursor: 'grab' }}
> >
{flagEmoji && <span className="text-base">{flagEmoji}</span>} {flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
<strong className="text-[13px] flex-1">{ship.name}</strong> <strong className="ship-popup-name">{ship.name}</strong>
{navyLabel && ( {navyLabel && (
<span <span className="ship-popup-navy-badge" style={{ background: navyAccent || color }}>
className="px-1.5 py-px rounded text-[10px] font-bold text-black" {navyLabel}
style={{ background: navyAccent || color }} </span>
>{navyLabel}</span>
)} )}
</div> </div>
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1"> {/* Photo */}
<span <VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} shipImageCount={ship.shipImageCount} />
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
style={{ background: color }} {/* Type tags */}
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span> <div className="ship-popup-tags">
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary"> <span className="ship-tag ship-tag-primary" style={{ background: color }}>
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}
</span>
<span className="ship-tag ship-tag-secondary">
{t(`categoryLabel.${ship.category}`)} {t(`categoryLabel.${ship.category}`)}
</span> </span>
{ship.typeDesc && ( {ship.typeDesc && (
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span> <span className="ship-tag ship-tag-dim">{ship.typeDesc}</span>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<div> {/* Data grid — paired rows */}
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div> <div className="ship-popup-grid">
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>} {/* Identity */}
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>} <div className="ship-popup-row">
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>} <span className="ship-popup-label">MMSI</span>
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>} <span className="ship-popup-value">{ship.mmsi}</span>
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
</div> </div>
<div> <div className="ship-popup-row">
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div> <span className="ship-popup-label">IMO</span>
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div> <span className="ship-popup-value">{ship.imo || '-'}</span>
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div> </div>
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
<div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div> {ship.callSign && (
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>} <>
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>} <div className="ship-popup-row">
<span className="ship-popup-label">{t('popup.callSign')}</span>
<span className="ship-popup-value">{ship.callSign}</span>
</div>
<div className="ship-popup-row" />
</>
)}
{/* Position — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">Lat</span>
<span className="ship-popup-value">{ship.lat.toFixed(4)}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Lon</span>
<span className="ship-popup-value">{ship.lng.toFixed(4)}</span>
</div>
{/* Navigation — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">HDG</span>
<span className="ship-popup-value">{ship.heading.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">COG</span>
<span className="ship-popup-value">{ship.course.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">SOG</span>
<span className="ship-popup-value">{ship.speed.toFixed(1)} kn</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Draught</span>
<span className="ship-popup-value">{ship.draught ? `${ship.draught.toFixed(2)}m` : '-'}</span>
</div>
{/* Dimensions — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">Length</span>
<span className="ship-popup-value">{ship.length ? `${ship.length}m` : '-'}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Width</span>
<span className="ship-popup-value">{ship.width ? `${ship.width}m` : '-'}</span>
</div> </div>
</div> </div>
<div className="mt-1.5 text-[9px] text-[#999] text-right">
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()} {/* Long-value fields — full width below grid */}
{ship.status && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Status</span>
<span className="ship-popup-value">{ship.status}</span>
</div> </div>
<div className="mt-1 text-[10px] text-right"> )}
{ship.destination && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Dest</span>
<span className="ship-popup-value">{ship.destination}</span>
</div>
)}
{ship.eta && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">ETA</span>
<span className="ship-popup-value">{new Date(ship.eta).toLocaleString()}</span>
</div>
)}
{/* Footer */}
<div className="ship-popup-footer">
<span className="ship-popup-timestamp">
{t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()}
</span>
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`} <a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank" rel="noopener noreferrer" className="text-kcg-accent"> target="_blank" rel="noopener noreferrer" className="ship-popup-link">
MarineTraffic &rarr; MarineTraffic &rarr;
</a> </a>
</div> </div>

파일 보기

@ -1,14 +1,16 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { fetchEvents, fetchSensorData } from '../services/api'; import { fetchEvents } from '../services/api';
import { fetchAircraftFromBackend } from '../services/aircraftApi'; import { fetchAircraftFromBackend } from '../services/aircraftApi';
import { getSampleAircraft } from '../data/sampleAircraft'; import { getSampleAircraft } from '../data/sampleAircraft';
import { fetchSatelliteTLE, propagateAll } from '../services/celestrak'; import { fetchSatelliteTLE, propagateAll } from '../services/celestrak';
import { fetchShips } from '../services/ships'; import { fetchShips } from '../services/ships';
import { fetchOsintFeed } from '../services/osint'; import { fetchOsintFeed } from '../services/osint';
import type { OsintItem } from '../services/osint'; import type { OsintItem } from '../services/osint';
import { fetchSeismic, fetchPressure } from '../services/sensorApi';
import type { SeismicDto, PressureDto } from '../services/sensorApi';
import { propagateAircraft, propagateShips } from '../services/propagation'; import { propagateAircraft, propagateShips } from '../services/propagation';
import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types';
interface UseIranDataArgs { interface UseIranDataArgs {
appMode: AppMode; appMode: AppMode;
@ -28,7 +30,8 @@ interface UseIranDataResult {
satPositions: SatellitePosition[]; satPositions: SatellitePosition[];
events: GeoEvent[]; events: GeoEvent[];
mergedEvents: GeoEvent[]; mergedEvents: GeoEvent[];
sensorData: SensorLog[]; seismicData: SeismicDto[];
pressureData: PressureDto[];
osintFeed: OsintItem[]; osintFeed: OsintItem[];
aircraftByCategory: Record<string, number>; aircraftByCategory: Record<string, number>;
militaryCount: number; militaryCount: number;
@ -37,6 +40,10 @@ interface UseIranDataResult {
koreanShipsByCategory: Record<string, number>; koreanShipsByCategory: Record<string, number>;
} }
const SENSOR_POLL_INTERVAL = 600_000; // 10 min
const SHIP_POLL_INTERVAL = 300_000; // 5 min
const SHIP_STALE_MS = 3_600_000; // 60 min
export function useIranData({ export function useIranData({
appMode, appMode,
currentTime, currentTime,
@ -47,7 +54,8 @@ export function useIranData({
dashboardTab, dashboardTab,
}: UseIranDataArgs): UseIranDataResult { }: UseIranDataArgs): UseIranDataResult {
const [events, setEvents] = useState<GeoEvent[]>([]); const [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]); const [seismicData, setSeismicData] = useState<SeismicDto[]>([]);
const [pressureData, setPressureData] = useState<PressureDto[]>([]);
const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]); const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]);
const [baseShips, setBaseShips] = useState<Ship[]>([]); const [baseShips, setBaseShips] = useState<Ship[]>([]);
const [satellites, setSatellites] = useState<Satellite[]>([]); const [satellites, setSatellites] = useState<Satellite[]>([]);
@ -55,14 +63,46 @@ export function useIranData({
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]); const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeRef = useRef(0); const satTimeRef = useRef(0);
const sensorInitRef = useRef(false);
const shipMapRef = useRef<Map<string, Ship>>(new Map());
// Load initial data // Load initial data
useEffect(() => { useEffect(() => {
fetchEvents().then(setEvents).catch(() => {}); fetchEvents().then(setEvents).catch(() => {});
fetchSensorData().then(setSensorData).catch(() => {});
fetchSatelliteTLE().then(setSatellites).catch(() => {}); fetchSatelliteTLE().then(setSatellites).catch(() => {});
}, [refreshKey]); }, [refreshKey]);
// Sensor data: initial full 48h load + 10min polling (incremental merge)
useEffect(() => {
const loadFull = async () => {
try {
const [seismic, pressure] = await Promise.all([
fetchSeismic(), // default 2880 min = 48h
fetchPressure(),
]);
setSeismicData(seismic);
setPressureData(pressure);
sensorInitRef.current = true;
} catch { /* keep previous */ }
};
const loadIncremental = async () => {
if (!sensorInitRef.current) return;
try {
const [seismic, pressure] = await Promise.all([
fetchSeismic(11), // 11 min window (overlap)
fetchPressure(11),
]);
setSeismicData(prev => mergeSensor(prev, seismic, s => s.usgsId, 2880));
setPressureData(prev => mergeSensor(prev, pressure, p => `${p.station}-${p.timestamp}`, 2880));
} catch { /* keep previous */ }
};
loadFull();
const interval = setInterval(loadIncremental, SENSOR_POLL_INTERVAL);
return () => clearInterval(interval);
}, [refreshKey]);
// Fetch base aircraft data (LIVE: backend, REPLAY: sample) // Fetch base aircraft data (LIVE: backend, REPLAY: sample)
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@ -78,22 +118,45 @@ export function useIranData({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [appMode, refreshKey]); }, [appMode, refreshKey]);
// Fetch Iran ship data (signal-batch + sample military, 5-min cycle) // Fetch Iran ship data: initial 60min, then 5min polling with 6min window + merge + stale cleanup
const mergeShips = useCallback((newShips: Ship[]) => {
const map = shipMapRef.current;
for (const s of newShips) {
map.set(s.mmsi, s);
}
// Remove stale ships (lastSeen > 60 min ago)
const cutoff = Date.now() - SHIP_STALE_MS;
for (const [mmsi, ship] of map) {
if (ship.lastSeen < cutoff) map.delete(mmsi);
}
setBaseShips(Array.from(map.values()));
}, []);
useEffect(() => { useEffect(() => {
const load = async () => { let initialDone = false;
const loadInitial = async () => {
try { try {
const data = await fetchShips(); const data = await fetchShips(60); // 초기: 60분 데이터
if (data.length > 0) { if (data.length > 0) {
shipMapRef.current = new Map(data.map(s => [s.mmsi, s]));
setBaseShips(data); setBaseShips(data);
initialDone = true;
} }
} catch { } catch { /* keep previous */ }
// keep previous data
}
}; };
load();
const interval = setInterval(load, 300_000); const loadIncremental = async () => {
if (!initialDone) return;
try {
const data = await fetchShips(6); // polling: 6분 데이터
if (data.length > 0) mergeShips(data);
} catch { /* keep previous */ }
};
loadInitial();
const interval = setInterval(loadIncremental, SHIP_POLL_INTERVAL);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [appMode, refreshKey]); }, [appMode, refreshKey, mergeShips]);
// Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab) // Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab)
useEffect(() => { useEffect(() => {
@ -245,7 +308,8 @@ export function useIranData({
satPositions, satPositions,
events, events,
mergedEvents, mergedEvents,
sensorData, seismicData,
pressureData,
osintFeed, osintFeed,
aircraftByCategory, aircraftByCategory,
militaryCount, militaryCount,
@ -254,3 +318,23 @@ export function useIranData({
koreanShipsByCategory, koreanShipsByCategory,
}; };
} }
/**
* 병합: , ,
*/
function mergeSensor<T extends { timestamp: number }>(
existing: T[],
incoming: T[],
keyFn: (item: T) => string,
maxMinutes: number,
): T[] {
const cutoff = Date.now() - maxMinutes * 60_000;
const map = new Map<string, T>();
for (const item of existing) {
if (item.timestamp >= cutoff) map.set(keyFn(item), item);
}
for (const item of incoming) {
if (item.timestamp >= cutoff) map.set(keyFn(item), item);
}
return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { fetchAircraftFromBackend } from '../services/aircraftApi'; import { fetchAircraftFromBackend } from '../services/aircraftApi';
import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak'; import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak';
import { fetchShipsKorea } from '../services/ships'; import { fetchShipsKorea } from '../services/ships';
@ -30,6 +30,9 @@ interface UseKoreaDataResult {
militaryCount: number; militaryCount: number;
} }
const SHIP_POLL_INTERVAL = 300_000; // 5 min
const SHIP_STALE_MS = 3_600_000; // 60 min
export function useKoreaData({ export function useKoreaData({
currentTime, currentTime,
isLive, isLive,
@ -44,6 +47,7 @@ export function useKoreaData({
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]); const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeKoreaRef = useRef(0); const satTimeKoreaRef = useRef(0);
const shipMapRef = useRef<Map<string, Ship>>(new Map());
// Fetch Korea satellite TLE data // Fetch Korea satellite TLE data
useEffect(() => { useEffect(() => {
@ -61,18 +65,45 @@ export function useKoreaData({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [refreshKey]); }, [refreshKey]);
// Fetch Korea region ship data (signal-batch, 4-min cycle) // Ship merge with stale cleanup
const mergeShips = useCallback((newShips: Ship[]) => {
const map = shipMapRef.current;
for (const s of newShips) {
map.set(s.mmsi, s);
}
const cutoff = Date.now() - SHIP_STALE_MS;
for (const [mmsi, ship] of map) {
if (ship.lastSeen < cutoff) map.delete(mmsi);
}
setBaseShipsKorea(Array.from(map.values()));
}, []);
// Fetch Korea region ship data: initial 60min, then 5min polling with 6min window
useEffect(() => { useEffect(() => {
const load = async () => { let initialDone = false;
const loadInitial = async () => {
try { try {
const data = await fetchShipsKorea(); const data = await fetchShipsKorea(60); // 초기: 60분 데이터
if (data.length > 0) setBaseShipsKorea(data); if (data.length > 0) {
shipMapRef.current = new Map(data.map(s => [s.mmsi, s]));
setBaseShipsKorea(data);
initialDone = true;
}
} catch { /* keep previous */ } } catch { /* keep previous */ }
}; };
load();
const interval = setInterval(load, 240_000); const loadIncremental = async () => {
if (!initialDone) return;
try {
const data = await fetchShipsKorea(6); // polling: 6분 데이터
if (data.length > 0) mergeShips(data);
} catch { /* keep previous */ }
};
loadInitial();
const interval = setInterval(loadIncremental, SHIP_POLL_INTERVAL);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [refreshKey]); }, [refreshKey, mergeShips]);
// Fetch OSINT feed for Korea tab // Fetch OSINT feed for Korea tab
useEffect(() => { useEffect(() => {

파일 보기

@ -0,0 +1,48 @@
const API_BASE = '/api/kcg/sensor';
export interface SeismicDto {
usgsId: string;
magnitude: number;
depth: number | null;
lat: number;
lng: number;
place: string;
timestamp: number; // epoch ms
}
export interface PressureDto {
station: string;
lat: number;
lng: number;
pressureHpa: number;
timestamp: number; // epoch ms
}
interface SensorResponse<T> {
count: number;
data: T[];
}
/**
*
* @param min (, 2880=48h)
*/
export async function fetchSeismic(min?: number): Promise<SeismicDto[]> {
const params = min != null ? `?min=${min}` : '';
const res = await fetch(`${API_BASE}/seismic${params}`);
if (!res.ok) throw new Error(`seismic API ${res.status}`);
const body: SensorResponse<SeismicDto> = await res.json();
return body.data;
}
/**
*
* @param min (, 2880=48h)
*/
export async function fetchPressure(min?: number): Promise<PressureDto[]> {
const params = min != null ? `?min=${min}` : '';
const res = await fetch(`${API_BASE}/pressure${params}`);
if (!res.ok) throw new Error(`pressure API ${res.status}`);
const body: SensorResponse<PressureDto> = await res.json();
return body.data;
}

파일 보기

@ -330,9 +330,9 @@ export async function fetchTankers(): Promise<Ship[]> {
// ═══ Main fetch function ═══ // ═══ Main fetch function ═══
// Tries signal-batch Iran region first, merges with sample military ships // Tries signal-batch Iran region first, merges with sample military ships
export async function fetchShips(): Promise<Ship[]> { export async function fetchShips(minutes = 10): Promise<Ship[]> {
const sampleShips = getSampleShips(); const sampleShips = getSampleShips();
const real = await fetchShipsFromSignalBatchIran(); const real = await fetchShipsFromSignalBatchIran(minutes);
if (real.length > 0) { if (real.length > 0) {
console.log(`signal-batch: ${real.length} vessels in Iran/Hormuz region`); console.log(`signal-batch: ${real.length} vessels in Iran/Hormuz region`);
@ -715,10 +715,10 @@ function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
}; };
} }
async function fetchShipsFromSignalBatchIran(): Promise<Ship[]> { async function fetchShipsFromSignalBatchIran(minutes = 10): Promise<Ship[]> {
try { try {
const body: RecentPositionDetailRequest = { const body: RecentPositionDetailRequest = {
minutes: 10, minutes,
coordinates: [ coordinates: [
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat], [IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat],
[IRAN_BOUNDS.maxLng, IRAN_BOUNDS.minLat], [IRAN_BOUNDS.maxLng, IRAN_BOUNDS.minLat],
@ -745,10 +745,10 @@ async function fetchShipsFromSignalBatchIran(): Promise<Ship[]> {
} }
} }
async function fetchShipsFromSignalBatch(): Promise<Ship[]> { async function fetchShipsFromSignalBatch(minutes = 5): Promise<Ship[]> {
try { try {
const body: RecentPositionDetailRequest = { const body: RecentPositionDetailRequest = {
minutes: 5, minutes,
coordinates: [ coordinates: [
[KR_BOUNDS.minLng, KR_BOUNDS.minLat], [KR_BOUNDS.minLng, KR_BOUNDS.minLat],
[KR_BOUNDS.maxLng, KR_BOUNDS.minLat], [KR_BOUNDS.maxLng, KR_BOUNDS.minLat],
@ -775,9 +775,9 @@ async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
} }
} }
export async function fetchShipsKorea(): Promise<Ship[]> { export async function fetchShipsKorea(minutes = 5): Promise<Ship[]> {
const sample = getSampleShipsKorea(); const sample = getSampleShipsKorea();
const real = await fetchShipsFromSignalBatch(); const real = await fetchShipsFromSignalBatch(minutes);
if (real.length > 0) { if (real.length > 0) {
console.log(`signal-batch: ${real.length} vessels in Korea region`); console.log(`signal-batch: ${real.length} vessels in Korea region`);
const sampleMMSIs = new Set(sample.map(s => s.mmsi)); const sampleMMSIs = new Set(sample.map(s => s.mmsi));

파일 보기

@ -90,9 +90,10 @@ export default defineConfig(({ mode }): UserConfig => ({
}, },
}, },
'/api/kcg': { '/api/kcg': {
target: 'http://localhost:8080', target: 'https://kcg.gc-si.dev',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'), rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'),
secure: true,
}, },
'/signal-batch': { '/signal-batch': {
target: 'https://wing.gc-si.dev', target: 'https://wing.gc-si.dev',