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:
부모
7fabe16f4f
커밋
6c54500c70
@ -83,5 +83,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
}
|
"deny": [],
|
||||||
|
"allow": []
|
||||||
|
}
|
||||||
@ -4,4 +4,4 @@
|
|||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
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');
|
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({
|
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>
|
||||||
<button
|
<div className="tz-radio-group">
|
||||||
type="button"
|
{(['KST', 'UTC'] as const).map(tz => (
|
||||||
onClick={() => setTimeZone(prev => prev === 'KST' ? 'UTC' : 'KST')}
|
<button
|
||||||
style={{
|
key={tz}
|
||||||
background: 'none',
|
type="button"
|
||||||
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
|
className={`tz-radio-btn ${timeZone === tz ? 'active' : ''}`}
|
||||||
color: 'var(--kcg-text-secondary, #94a3b8)',
|
onClick={() => onTimeZoneChange(tz)}
|
||||||
borderRadius: '3px',
|
>
|
||||||
padding: '1px 5px',
|
{tz}
|
||||||
cursor: 'pointer',
|
</button>
|
||||||
fontSize: '10px',
|
))}
|
||||||
fontFamily: 'monospace',
|
</div>
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
title="KST/UTC 전환"
|
|
||||||
>
|
|
||||||
{timeZone}
|
|
||||||
</button>
|
|
||||||
</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">
|
||||||
<img src={localUrl} alt="Vessel"
|
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
|
||||||
className="w-full rounded block"
|
<img src={localUrl} alt="Vessel"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
className="w-full h-full object-contain"
|
||||||
/>
|
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>
|
||||||
{currentUrl ? (
|
|
||||||
<img src={currentUrl} alt="Vessel"
|
{/* 고정 높이 사진 영역 */}
|
||||||
className="w-full rounded block"
|
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
|
||||||
onError={(e) => {
|
{currentUrl ? (
|
||||||
const el = e.target as HTMLImageElement;
|
<img
|
||||||
if (activeTab === 'spglobal') {
|
key={currentUrl}
|
||||||
setSpgError(true);
|
src={currentUrl}
|
||||||
el.style.display = 'none';
|
alt="Vessel"
|
||||||
} else {
|
className="w-full h-full object-contain"
|
||||||
el.style.display = 'none';
|
onError={() => {
|
||||||
}
|
if (activeTab === 'spglobal') {
|
||||||
}}
|
setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
|
||||||
/>
|
}
|
||||||
) : noPhoto ? (
|
}}
|
||||||
<div className="text-center py-3 text-kcg-dim text-[10px] border border-dashed border-kcg-border-light rounded">
|
/>
|
||||||
No photo available
|
) : noPhoto ? (
|
||||||
</div>
|
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
|
||||||
) : (
|
No photo available
|
||||||
activeTab === 'marinetraffic' && mtPhoto === undefined
|
</div>
|
||||||
? <div className="text-center py-3 text-kcg-dim text-[10px]">Loading...</div>
|
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
|
||||||
: <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
|
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); }}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</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); }}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</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>
|
</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)}°</div>
|
<span className="ship-popup-label">IMO</span>
|
||||||
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}°</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)}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="ship-popup-row">
|
||||||
|
<span className="ship-popup-label">COG</span>
|
||||||
|
<span className="ship-popup-value">{ship.course.toFixed(1)}°</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 */}
|
||||||
</div>
|
{ship.status && (
|
||||||
<div className="mt-1 text-[10px] text-right">
|
<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}`}
|
<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 →
|
MarineTraffic →
|
||||||
</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(() => {
|
||||||
|
|||||||
48
frontend/src/services/sensorApi.ts
Normal file
48
frontend/src/services/sensorApi.ts
Normal file
@ -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',
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user