Merge pull request 'release: 2026-03-18.3 (10건 커밋)' (#53) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m16s

This commit is contained in:
htlee 2026-03-18 09:37:50 +09:00
커밋 d0c8b3d1bd
16개의 변경된 파일873개의 추가작업 그리고 200개의 파일을 삭제

파일 보기

@ -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 AUTH_PATH_PREFIX = "/api/auth/";
private static final String SENSOR_PATH_PREFIX = "/api/sensor/";
private final JwtProvider jwtProvider;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith(AUTH_PATH_PREFIX);
return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX);
}
@Override

파일 보기

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

파일 보기

@ -4,6 +4,32 @@
## [Unreleased]
## [2026-03-18.3]
### 추가
- 센서 API 서비스(sensorApi.ts): 백엔드 지진/기압 실데이터 연동
- 선박 모달 S&P Global 다중 사진 슬라이드 (좌우 화살표 + 인디케이터)
- 선박 모달 드래그 이동 (헤더 영역 grab)
- LiveControls KST/UTC 라디오 버튼 그룹
### 변경
- SensorChart: 더미 → 실데이터(지진/기압), x축 동적 시간 표시
- 히스토리 프리셋: 30M/1H/3H/6H/12H/24H → 10M/30M/1H/3H/6H (8칸 구조)
- 센서 API 파라미터: hours → min (기본 2880=48h)
- 센서 데이터 polling: 초기 48h 전체 → 10분마다 incremental merge
- 선박 데이터 polling: 초기 60분 → 5분마다 6분 윈도우 merge + 60분 stale 제거
- 선박 모달 고정 크기(300px) + 사진 영역 고정(160px, object-contain)
- 선박 모달 데이터 레이아웃: 2컬럼 그리드 + 연관 정보 쌍 배치 + 긴 값 단독행
- 선박 모달 CSS 통일 (태그 패딩/배경, 컬럼 간격 12px)
### 수정
- 센서 API(/api/sensor/*) 인증 예외 처리 (공개 데이터)
- 선박 모달 열 때마다 S&P Global 우선 탭 리셋 (MarineTraffic 포커스 유지 버그)
- S&P Global 사진 URL: IMO 기반 이미지 목록 API 연동 (잘못된 번호 패턴 제거)
### 기타
- 로그인 화면 KCG 로고에 DEMO 문구 오버레이
## [2026-03-18.2]
### 추가

파일 보기

@ -1137,6 +1137,157 @@
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 */
.app-footer {
background: var(--bg-card);
@ -1890,6 +2041,39 @@
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 */
.app-live .app-header {
border-bottom-color: rgba(239, 68, 68, 0.3);

파일 보기

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

파일 보기

@ -104,7 +104,22 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
>
{/* Title */}
<div className="flex flex-col items-center gap-2">
<img src="/kcg.svg" alt="KCG" style={{ width: 120, height: 120 }} />
<div className="relative inline-block">
<img src="/kcg.svg" alt="KCG" style={{ width: 120, height: 120 }} />
<span
className="absolute font-black tracking-widest"
style={{
bottom: 2,
right: -8,
fontSize: 14,
color: 'var(--kcg-danger)',
opacity: 0.85,
textShadow: '0 0 4px rgba(0,0,0,0.6)',
}}
>
DEMO
</span>
</div>
<h1
className="text-xl font-bold"
style={{ color: 'var(--kcg-text)' }}

파일 보기

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

파일 보기

@ -8,33 +8,122 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import type { SensorLog } from '../../types';
import type { SeismicDto, PressureDto } from '../../services/sensorApi';
interface Props {
data: SensorLog[];
seismicData: SeismicDto[];
pressureData: PressureDto[];
currentTime: number;
startTime: number;
endTime: number;
historyMinutes: 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 visibleData = useMemo(
() => data.filter(d => d.timestamp <= currentTime),
[data, currentTime],
const totalMinutes = historyMinutes * 8;
const rangeStart = currentTime - totalMinutes * MINUTE;
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(
() =>
visibleData.map(d => ({
...d,
time: formatHour(d.timestamp, startTime),
})),
[visibleData, startTime],
);
const commonXAxis = {
dataKey: 'time' as const,
type: 'number' as const,
domain: [rangeStart, rangeEnd] as [number, number],
ticks,
tickFormatter: formatTickTime,
tick: { fontSize: 9, fill: '#888' },
};
return (
<div className="sensor-chart">
@ -43,13 +132,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<div className="chart-item">
<h4>{t('sensor.seismicActivity')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<LineChart data={seismicChart}>
<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' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<ReferenceLine x={formatHour(currentTime, startTime)} stroke="#fff" strokeDasharray="3 3" />
<Line type="monotone" dataKey="seismic" stroke="#ef4444" dot={false} strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'Magnitude×10']}
/>
<Line type="monotone" dataKey="value" stroke="#ef4444" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
@ -57,12 +149,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<LineChart data={pressureChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[990, 1020]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="airPressure" stroke="#3b82f6" dot={false} strokeWidth={1.5} />
<XAxis {...commonXAxis} />
<YAxis domain={[990, 1030]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
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>
</ResponsiveContainer>
</div>
@ -73,12 +169,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<LineChart data={noiseChart}>
<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' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
<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>
@ -89,12 +189,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<LineChart data={radiationChart}>
<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' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="radiationLevel" stroke="#22c55e" dot={false} strokeWidth={1.5} />
<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>
@ -102,10 +206,3 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</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 { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../../types';
@ -135,27 +135,78 @@ interface VesselPhotoProps {
mmsi: string;
imo?: string;
shipImagePath?: string | null;
shipImageCount?: number;
}
function toHighRes(path: string): string {
return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1');
/**
* S&P Global API
* GET /signal-batch/api/v1/shipimg/{imo}
* path에 _1.jpg() / _2.jpg()
*/
interface SpgImageInfo {
picId: number;
path: string; // e.g. "/shipimg/22738/2273823"
copyright: string;
date: string;
}
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
// IMO별 이미지 목록 캐시
const spgImageCache = new Map<string, SpgImageInfo[] | null>();
async function fetchSpgImages(imo: string): Promise<SpgImageInfo[]> {
if (spgImageCache.has(imo)) return spgImageCache.get(imo) || [];
try {
const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`);
if (!res.ok) throw new Error(`${res.status}`);
const data: SpgImageInfo[] = await res.json();
spgImageCache.set(imo, data);
return data;
} catch {
spgImageCache.set(imo, null);
return [];
}
}
function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
const hasSPGlobal = !!shipImagePath;
const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic';
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());
const [spgImages, setSpgImages] = useState<SpgImageInfo[]>([]);
// S&P Global image error state
const [spgError, setSpgError] = useState(false);
// 모달이 다른 선박으로 변경될 때 리셋 + 이미지 목록 조회
useEffect(() => {
setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic');
setSpgSlideIdx(0);
setSpgErrors(new Set());
setSpgImages([]);
if (imo && hasSPGlobal) {
fetchSpgImages(imo).then(setSpgImages);
} else if (shipImagePath) {
// IMO 없으면 shipImagePath 단일 이미지 사용
setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]);
}
}, [mmsi, imo, hasSPGlobal, shipImagePath]);
// S&P Global slide URLs: 각 이미지의 path + _2.jpg (원본)
const spgUrls = useMemo(
() => spgImages.map(img => `${img.path}_2.jpg`),
[spgImages],
);
const validSpgCount = spgUrls.length;
// MarineTraffic image state (lazy loaded)
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
});
useEffect(() => {
setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined);
}, [mmsi]);
useEffect(() => {
if (activeTab !== 'marinetraffic') return;
if (mtPhoto !== undefined) return;
@ -170,8 +221,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
let currentUrl: string | null = null;
if (localUrl) {
currentUrl = localUrl;
} else if (activeTab === 'spglobal' && shipImagePath && !spgError) {
currentUrl = toHighRes(shipImagePath);
} else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) {
currentUrl = spgUrls[spgSlideIdx];
} else if (activeTab === 'marinetraffic' && mtPhoto) {
currentUrl = mtPhoto.url;
}
@ -180,15 +231,18 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
if (localUrl) {
return (
<div className="mb-1.5">
<img src={localUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
<img src={localUrl} alt="Vessel"
className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</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 (
<div className="mb-1.5">
@ -212,40 +266,67 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
MarineTraffic
</div>
</div>
{currentUrl ? (
<img src={currentUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => {
const el = e.target as HTMLImageElement;
if (activeTab === 'spglobal') {
setSpgError(true);
el.style.display = 'none';
} else {
el.style.display = 'none';
}
}}
/>
) : noPhoto ? (
<div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded">
No photo available
</div>
) : (
activeTab === 'marinetraffic' && mtPhoto === undefined
? <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
{/* 고정 높이 사진 영역 */}
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
{currentUrl ? (
<img
key={currentUrl}
src={currentUrl}
alt="Vessel"
className="w-full h-full object-contain"
onError={() => {
if (activeTab === 'spglobal') {
setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
}
}}
/>
) : noPhoto ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</div>
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
Loading...
</div>
) : (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</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>
);
}
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
const TRIANGLE_SIZE = 64;
@ -422,63 +503,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 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 (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
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
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
className="ship-popup-header"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0', cursor: 'grab' }}
>
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
<strong className="text-[13px] flex-1">{ship.name}</strong>
{flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
<strong className="ship-popup-name">{ship.name}</strong>
{navyLabel && (
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
style={{ background: navyAccent || color }}
>{navyLabel}</span>
<span className="ship-popup-navy-badge" style={{ background: navyAccent || color }}>
{navyLabel}
</span>
)}
</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">
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
{/* Photo */}
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} shipImageCount={ship.shipImageCount} />
{/* Type tags */}
<div className="ship-popup-tags">
<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}`)}
</span>
{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 className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<div>
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
{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>}
{/* Data grid — paired rows */}
<div className="ship-popup-grid">
{/* Identity */}
<div className="ship-popup-row">
<span className="ship-popup-label">MMSI</span>
<span className="ship-popup-value">{ship.mmsi}</span>
</div>
<div>
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</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.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">IMO</span>
<span className="ship-popup-value">{ship.imo || '-'}</span>
</div>
{ship.callSign && (
<>
<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 className="mt-1.5 text-[9px] text-[#999] text-right">
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
</div>
<div className="mt-1 text-[10px] text-right">
{/* 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>
)}
{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}`}
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
target="_blank" rel="noopener noreferrer" className="ship-popup-link">
MarineTraffic &rarr;
</a>
</div>

파일 보기

@ -1,14 +1,16 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { fetchEvents, fetchSensorData } from '../services/api';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { fetchEvents } from '../services/api';
import { fetchAircraftFromBackend } from '../services/aircraftApi';
import { getSampleAircraft } from '../data/sampleAircraft';
import { fetchSatelliteTLE, propagateAll } from '../services/celestrak';
import { fetchShips } from '../services/ships';
import { fetchOsintFeed } 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 { 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 {
appMode: AppMode;
@ -28,7 +30,8 @@ interface UseIranDataResult {
satPositions: SatellitePosition[];
events: GeoEvent[];
mergedEvents: GeoEvent[];
sensorData: SensorLog[];
seismicData: SeismicDto[];
pressureData: PressureDto[];
osintFeed: OsintItem[];
aircraftByCategory: Record<string, number>;
militaryCount: number;
@ -37,6 +40,10 @@ interface UseIranDataResult {
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({
appMode,
currentTime,
@ -47,7 +54,8 @@ export function useIranData({
dashboardTab,
}: UseIranDataArgs): UseIranDataResult {
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 [baseShips, setBaseShips] = useState<Ship[]>([]);
const [satellites, setSatellites] = useState<Satellite[]>([]);
@ -55,14 +63,46 @@ export function useIranData({
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeRef = useRef(0);
const sensorInitRef = useRef(false);
const shipMapRef = useRef<Map<string, Ship>>(new Map());
// Load initial data
useEffect(() => {
fetchEvents().then(setEvents).catch(() => {});
fetchSensorData().then(setSensorData).catch(() => {});
fetchSatelliteTLE().then(setSatellites).catch(() => {});
}, [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)
useEffect(() => {
const load = async () => {
@ -78,22 +118,45 @@ export function useIranData({
return () => clearInterval(interval);
}, [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(() => {
const load = async () => {
let initialDone = false;
const loadInitial = async () => {
try {
const data = await fetchShips();
const data = await fetchShips(60); // 초기: 60분 데이터
if (data.length > 0) {
shipMapRef.current = new Map(data.map(s => [s.mmsi, s]));
setBaseShips(data);
initialDone = true;
}
} catch {
// keep previous data
}
} catch { /* keep previous */ }
};
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);
}, [appMode, refreshKey]);
}, [appMode, refreshKey, mergeShips]);
// Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab)
useEffect(() => {
@ -245,7 +308,8 @@ export function useIranData({
satPositions,
events,
mergedEvents,
sensorData,
seismicData,
pressureData,
osintFeed,
aircraftByCategory,
militaryCount,
@ -254,3 +318,23 @@ export function useIranData({
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 { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak';
import { fetchShipsKorea } from '../services/ships';
@ -30,6 +30,9 @@ interface UseKoreaDataResult {
militaryCount: number;
}
const SHIP_POLL_INTERVAL = 300_000; // 5 min
const SHIP_STALE_MS = 3_600_000; // 60 min
export function useKoreaData({
currentTime,
isLive,
@ -44,6 +47,7 @@ export function useKoreaData({
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeKoreaRef = useRef(0);
const shipMapRef = useRef<Map<string, Ship>>(new Map());
// Fetch Korea satellite TLE data
useEffect(() => {
@ -61,18 +65,45 @@ export function useKoreaData({
return () => clearInterval(interval);
}, [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(() => {
const load = async () => {
let initialDone = false;
const loadInitial = async () => {
try {
const data = await fetchShipsKorea();
if (data.length > 0) setBaseShipsKorea(data);
const data = await fetchShipsKorea(60); // 초기: 60분 데이터
if (data.length > 0) {
shipMapRef.current = new Map(data.map(s => [s.mmsi, s]));
setBaseShipsKorea(data);
initialDone = true;
}
} 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);
}, [refreshKey]);
}, [refreshKey, mergeShips]);
// Fetch OSINT feed for Korea tab
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 ═══
// 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 real = await fetchShipsFromSignalBatchIran();
const real = await fetchShipsFromSignalBatchIran(minutes);
if (real.length > 0) {
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 {
const body: RecentPositionDetailRequest = {
minutes: 10,
minutes,
coordinates: [
[IRAN_BOUNDS.minLng, 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 {
const body: RecentPositionDetailRequest = {
minutes: 5,
minutes,
coordinates: [
[KR_BOUNDS.minLng, 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 real = await fetchShipsFromSignalBatch();
const real = await fetchShipsFromSignalBatch(minutes);
if (real.length > 0) {
console.log(`signal-batch: ${real.length} vessels in Korea region`);
const sampleMMSIs = new Set(sample.map(s => s.mmsi));

파일 보기

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