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
All checks were successful
Deploy KCG / deploy (push) Successful in 1m16s
This commit is contained in:
커밋
d0c8b3d1bd
@ -83,5 +83,7 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
@ -4,4 +4,4 @@
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"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 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); }}
|
||||
>
|
||||
<
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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)}°</div>
|
||||
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}°</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)}°</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 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 →
|
||||
</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(() => {
|
||||
|
||||
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 ═══
|
||||
// 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',
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user