Merge pull request 'release: 2026-03-18.2 (8건 커밋)' (#41) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 16s
Some checks failed
Deploy KCG / deploy (push) Failing after 16s
This commit is contained in:
커밋
606083dce9
@ -0,0 +1,113 @@
|
||||
package gc.mda.kcg.collector.sensor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.domain.sensor.PressureReading;
|
||||
import gc.mda.kcg.domain.sensor.PressureReadingRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PressureCollector {
|
||||
|
||||
private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"};
|
||||
private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08};
|
||||
private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29};
|
||||
|
||||
private static final String OPEN_METEO_URL =
|
||||
"https://api.open-meteo.com/v1/forecast?" +
|
||||
"latitude=35.69,32.65,27.19,29.59,38.08" +
|
||||
"&longitude=51.39,51.68,56.27,52.58,46.29" +
|
||||
"&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PressureReadingRepository repository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("pressure-init").start(() -> {
|
||||
log.info("Open-Meteo 기압 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)
|
||||
public void collectScheduled() {
|
||||
collect();
|
||||
}
|
||||
|
||||
private void collect() {
|
||||
try {
|
||||
String body = restTemplate.getForObject(OPEN_METEO_URL, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
int saved = 0;
|
||||
Instant now = Instant.now();
|
||||
|
||||
// Open-Meteo 멀티 위치: 배열 형태 응답
|
||||
if (root.isArray()) {
|
||||
for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) {
|
||||
saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now);
|
||||
}
|
||||
} else {
|
||||
// 단일 위치 응답 (파라미터 1개일 때)
|
||||
saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now);
|
||||
}
|
||||
|
||||
tracker.recordSuccess("pressure", "iran", saved);
|
||||
log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved);
|
||||
} catch (Exception e) {
|
||||
tracker.recordFailure("pressure", "iran", e.getMessage());
|
||||
log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) {
|
||||
JsonNode hourly = stationData.path("hourly");
|
||||
JsonNode times = hourly.path("time");
|
||||
JsonNode pressures = hourly.path("surface_pressure");
|
||||
if (!times.isArray() || !pressures.isArray()) return 0;
|
||||
|
||||
int saved = 0;
|
||||
for (int j = 0; j < times.size() && j < pressures.size(); j++) {
|
||||
String timeStr = times.get(j).asText();
|
||||
double pressure = pressures.get(j).asDouble(Double.NaN);
|
||||
if (Double.isNaN(pressure)) continue;
|
||||
|
||||
try {
|
||||
// Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) → Z 추가
|
||||
Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant();
|
||||
try {
|
||||
repository.save(PressureReading.builder()
|
||||
.station(station)
|
||||
.lat(lat)
|
||||
.lng(lng)
|
||||
.pressureHpa(pressure)
|
||||
.readingTime(readingTime)
|
||||
.collectedAt(now)
|
||||
.build());
|
||||
saved++;
|
||||
} catch (Exception ignored) {
|
||||
// unique constraint violation = 이미 존재, 무시
|
||||
}
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("기압 시간 파싱 실패: {}", timeStr);
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package gc.mda.kcg.collector.sensor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.domain.sensor.SeismicEvent;
|
||||
import gc.mda.kcg.domain.sensor.SeismicEventRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SeismicCollector {
|
||||
|
||||
private static final String USGS_URL =
|
||||
"https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" +
|
||||
"&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" +
|
||||
"&orderby=time&limit=50&minmagnitude=2";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SeismicEventRepository repository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("seismic-init").start(() -> {
|
||||
log.info("USGS 지진 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
|
||||
public void collectScheduled() {
|
||||
collect();
|
||||
}
|
||||
|
||||
private void collect() {
|
||||
try {
|
||||
String body = restTemplate.getForObject(USGS_URL, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode features = root.path("features");
|
||||
if (!features.isArray()) return;
|
||||
|
||||
int saved = 0;
|
||||
Instant now = Instant.now();
|
||||
for (JsonNode f : features) {
|
||||
JsonNode props = f.path("properties");
|
||||
JsonNode coords = f.path("geometry").path("coordinates");
|
||||
|
||||
String usgsId = f.path("id").asText(null);
|
||||
if (usgsId == null) continue;
|
||||
if (repository.existsByUsgsId(usgsId)) continue;
|
||||
|
||||
double mag = props.path("mag").asDouble(0);
|
||||
String place = props.path("place").asText(null);
|
||||
long timeMs = props.path("time").asLong(0);
|
||||
double lng = coords.get(0).asDouble();
|
||||
double lat = coords.get(1).asDouble();
|
||||
Double depth = coords.has(2) ? coords.get(2).asDouble() : null;
|
||||
|
||||
repository.save(SeismicEvent.builder()
|
||||
.usgsId(usgsId)
|
||||
.magnitude(mag)
|
||||
.depth(depth)
|
||||
.lat(lat)
|
||||
.lng(lng)
|
||||
.place(place)
|
||||
.eventTime(Instant.ofEpochMilli(timeMs))
|
||||
.collectedAt(now)
|
||||
.build());
|
||||
saved++;
|
||||
}
|
||||
tracker.recordSuccess("seismic", "iran", saved);
|
||||
log.debug("USGS 지진 수집 완료: {}건 저장", saved);
|
||||
} catch (Exception e) {
|
||||
tracker.recordFailure("seismic", "iran", e.getMessage());
|
||||
log.warn("USGS 지진 수집 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,13 +18,16 @@ public class CacheConfig {
|
||||
public static final String OSINT_IRAN = "osint-iran";
|
||||
public static final String OSINT_KOREA = "osint-korea";
|
||||
public static final String SATELLITES = "satellites";
|
||||
public static final String SEISMIC = "seismic";
|
||||
public static final String PRESSURE = "pressure";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager(
|
||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||
OSINT_IRAN, OSINT_KOREA,
|
||||
SATELLITES
|
||||
SATELLITES,
|
||||
SEISMIC, PRESSURE
|
||||
);
|
||||
manager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "pressure_readings",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
|
||||
)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PressureReading {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String station;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lat;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lng;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double pressureHpa;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant readingTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface PressureReadingRepository extends JpaRepository<PressureReading, Long> {
|
||||
List<PressureReading> findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since);
|
||||
List<PressureReading> findByReadingTimeAfterOrderByReadingTimeAsc(Instant since);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "seismic_events", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SeismicEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 30)
|
||||
private String usgsId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double magnitude;
|
||||
|
||||
private Double depth;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lat;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lng;
|
||||
|
||||
@Column(length = 255)
|
||||
private String place;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant eventTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface SeismicEventRepository extends JpaRepository<SeismicEvent, Long> {
|
||||
boolean existsByUsgsId(String usgsId);
|
||||
List<SeismicEvent> findByEventTimeAfterOrderByEventTimeDesc(Instant since);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sensor")
|
||||
@RequiredArgsConstructor
|
||||
public class SensorController {
|
||||
|
||||
private final SeismicEventRepository seismicRepo;
|
||||
private final PressureReadingRepository pressureRepo;
|
||||
|
||||
/**
|
||||
* 지진 이벤트 조회 (USGS 수집 데이터)
|
||||
*/
|
||||
@GetMapping("/seismic")
|
||||
public Map<String, Object> getSeismic(
|
||||
@RequestParam(defaultValue = "24") int hours) {
|
||||
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||
List<SensorDto.SeismicDto> data = seismicRepo
|
||||
.findByEventTimeAfterOrderByEventTimeDesc(since)
|
||||
.stream().map(SensorDto.SeismicDto::from).toList();
|
||||
return Map.of("count", data.size(), "data", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기압 데이터 조회 (Open-Meteo 수집 데이터)
|
||||
*/
|
||||
@GetMapping("/pressure")
|
||||
public Map<String, Object> getPressure(
|
||||
@RequestParam(defaultValue = "24") int hours) {
|
||||
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||
List<SensorDto.PressureDto> data = pressureRepo
|
||||
.findByReadingTimeAfterOrderByReadingTimeAsc(since)
|
||||
.stream().map(SensorDto.PressureDto::from).toList();
|
||||
return Map.of("count", data.size(), "data", data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
public class SensorDto {
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class SeismicDto {
|
||||
private String usgsId;
|
||||
private double magnitude;
|
||||
private Double depth;
|
||||
private double lat;
|
||||
private double lng;
|
||||
private String place;
|
||||
private long timestamp; // epoch ms
|
||||
|
||||
public static SeismicDto from(SeismicEvent e) {
|
||||
return SeismicDto.builder()
|
||||
.usgsId(e.getUsgsId())
|
||||
.magnitude(e.getMagnitude())
|
||||
.depth(e.getDepth())
|
||||
.lat(e.getLat())
|
||||
.lng(e.getLng())
|
||||
.place(e.getPlace())
|
||||
.timestamp(e.getEventTime().toEpochMilli())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class PressureDto {
|
||||
private String station;
|
||||
private double lat;
|
||||
private double lng;
|
||||
private double pressureHpa;
|
||||
private long timestamp; // epoch ms
|
||||
|
||||
public static PressureDto from(PressureReading r) {
|
||||
return PressureDto.builder()
|
||||
.station(r.getStation())
|
||||
.lat(r.getLat())
|
||||
.lng(r.getLng())
|
||||
.pressureHpa(r.getPressureHpa())
|
||||
.timestamp(r.getReadingTime().toEpochMilli())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
database/migration/004_sensor_data.sql
Normal file
30
database/migration/004_sensor_data.sql
Normal file
@ -0,0 +1,30 @@
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
-- 지진 이벤트 (USGS)
|
||||
CREATE TABLE IF NOT EXISTS seismic_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
usgs_id VARCHAR(30) UNIQUE NOT NULL,
|
||||
magnitude DOUBLE PRECISION NOT NULL,
|
||||
depth DOUBLE PRECISION,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
place VARCHAR(255),
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC);
|
||||
|
||||
-- 기압 데이터 (Open-Meteo)
|
||||
CREATE TABLE IF NOT EXISTS pressure_readings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
station VARCHAR(50) NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
pressure_hpa DOUBLE PRECISION NOT NULL,
|
||||
reading_time TIMESTAMPTZ NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(station, reading_time)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC);
|
||||
@ -49,8 +49,8 @@ server {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ── 선박 이미지 프록시 ──
|
||||
location /shipimg/ {
|
||||
# ── 선박 이미지 프록시 (^~ = regex 정적캐시 규칙보다 우선) ──
|
||||
location ^~ /shipimg/ {
|
||||
proxy_pass https://wing.gc-si.dev/shipimg/;
|
||||
proxy_set_header Host wing.gc-si.dev;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
@ -4,6 +4,25 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-18.2]
|
||||
|
||||
### 추가
|
||||
- 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기
|
||||
- 기압 수집기: Open-Meteo API, 이란 5개 관측점, 10분 주기
|
||||
- DB: seismic_events, pressure_readings 테이블 (마이그레이션 004)
|
||||
- REST: GET /api/sensor/seismic, GET /api/sensor/pressure
|
||||
|
||||
### 변경
|
||||
- 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리
|
||||
- App.tsx 분해: 1,179줄 → 588줄 (useIranData, useKoreaData, useKoreaFilters 훅 추출)
|
||||
- SensorChart 그래프 순서: 지진파 → 기압 → 소음(DEMO) → 방사선(DEMO)
|
||||
- 선박 모달 사진 탭: S&P Global 명칭, 고화질(_2) 기본 표시
|
||||
- Overpass API 외부 호출 제거 → 정적 인프라 데이터
|
||||
|
||||
### 수정
|
||||
- LiveControls KST 시간 이중 오프셋(+9h×2) 수정 + KST/UTC 토글
|
||||
- nginx /shipimg/ 프록시: ^~ 추가 (정적파일 regex 우선매칭 방지)
|
||||
|
||||
## [2026-03-18]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -1131,6 +1131,12 @@
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.chart-demo-label {
|
||||
font-size: 9px;
|
||||
color: #ef4444;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Footer / Controls */
|
||||
.app-footer {
|
||||
background: var(--bg-card);
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,57 +0,0 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
historyMinutes: number;
|
||||
onHistoryChange: (minutes: number) => void;
|
||||
aircraftCount: number;
|
||||
shipCount: number;
|
||||
satelliteCount: number;
|
||||
}
|
||||
|
||||
const HISTORY_PRESETS = [
|
||||
{ label: '30M', minutes: 30 },
|
||||
{ label: '1H', minutes: 60 },
|
||||
{ label: '3H', minutes: 180 },
|
||||
{ label: '6H', minutes: 360 },
|
||||
{ label: '12H', minutes: 720 },
|
||||
{ label: '24H', minutes: 1440 },
|
||||
];
|
||||
|
||||
export function LiveControls({
|
||||
currentTime,
|
||||
historyMinutes,
|
||||
onHistoryChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
||||
|
||||
return (
|
||||
<div className="live-controls">
|
||||
<div className="live-indicator">
|
||||
<span className="live-dot" />
|
||||
<span className="live-label">{t('header.live')}</span>
|
||||
</div>
|
||||
|
||||
<div className="live-clock">{kstTime}</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="history-controls">
|
||||
<span className="history-label">{t('time.history')}</span>
|
||||
<div className="history-presets">
|
||||
{HISTORY_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={`history-btn ${historyMinutes === p.minutes ? 'active' : ''}`}
|
||||
onClick={() => onHistoryChange(p.minutes)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/common/LiveControls.tsx
Normal file
88
frontend/src/components/common/LiveControls.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
historyMinutes: number;
|
||||
onHistoryChange: (minutes: number) => void;
|
||||
aircraftCount: number;
|
||||
shipCount: number;
|
||||
satelliteCount: number;
|
||||
}
|
||||
|
||||
const HISTORY_PRESETS = [
|
||||
{ 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`;
|
||||
}
|
||||
|
||||
export function LiveControls({
|
||||
currentTime,
|
||||
historyMinutes,
|
||||
onHistoryChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||
|
||||
return (
|
||||
<div className="live-controls">
|
||||
<div className="live-indicator">
|
||||
<span className="live-dot" />
|
||||
<span className="live-label">{t('header.live')}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="history-controls">
|
||||
<span className="history-label">{t('time.history')}</span>
|
||||
<div className="history-presets">
|
||||
{HISTORY_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={`history-btn ${historyMinutes === p.minutes ? 'active' : ''}`}
|
||||
onClick={() => onHistoryChange(p.minutes)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.noiseLevelDb')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<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} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.radiationUsv')}</h4>
|
||||
<h4>
|
||||
{t('sensor.noiseLevelDb')}{' '}
|
||||
<span className="chart-demo-label">(DEMO)</span>
|
||||
</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<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} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>
|
||||
{t('sensor.radiationUsv')}{' '}
|
||||
<span className="chart-demo-label">(DEMO)</span>
|
||||
</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
@ -2,10 +2,10 @@ import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { DamagedShipLayer } from './DamagedShipLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { iranOilFacilities } from '../data/oilFacilities';
|
||||
@ -2,10 +2,10 @@ import { useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { DamagedShipLayer } from './DamagedShipLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { iranOilFacilities } from '../data/oilFacilities';
|
||||
@ -2,10 +2,10 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
import { SubmarineCableLayer } from './SubmarineCableLayer';
|
||||
import { CctvLayer } from './CctvLayer';
|
||||
import { KoreaAirportLayer } from './KoreaAirportLayer';
|
||||
@ -129,7 +129,7 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
||||
interface VesselPhotoData { url: string; }
|
||||
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
||||
|
||||
type PhotoSource = 'signal-batch' | 'marinetraffic';
|
||||
type PhotoSource = 'spglobal' | 'marinetraffic';
|
||||
|
||||
interface VesselPhotoProps {
|
||||
mmsi: string;
|
||||
@ -137,15 +137,20 @@ interface VesselPhotoProps {
|
||||
shipImagePath?: string | null;
|
||||
}
|
||||
|
||||
function toHighRes(path: string): string {
|
||||
return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1');
|
||||
}
|
||||
|
||||
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||
const { t } = useTranslation('ships');
|
||||
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
||||
|
||||
// Determine available tabs
|
||||
const hasSignalBatch = !!shipImagePath;
|
||||
const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic';
|
||||
const hasSPGlobal = !!shipImagePath;
|
||||
const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic';
|
||||
const [activeTab, setActiveTab] = useState<PhotoSource>(defaultTab);
|
||||
|
||||
// S&P Global image error state
|
||||
const [spgError, setSpgError] = useState(false);
|
||||
|
||||
// MarineTraffic image state (lazy loaded)
|
||||
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
||||
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
||||
@ -165,8 +170,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||
let currentUrl: string | null = null;
|
||||
if (localUrl) {
|
||||
currentUrl = localUrl;
|
||||
} else if (activeTab === 'signal-batch' && shipImagePath) {
|
||||
currentUrl = shipImagePath;
|
||||
} else if (activeTab === 'spglobal' && shipImagePath && !spgError) {
|
||||
currentUrl = toHighRes(shipImagePath);
|
||||
} else if (activeTab === 'marinetraffic' && mtPhoto) {
|
||||
currentUrl = mtPhoto.url;
|
||||
}
|
||||
@ -183,17 +188,19 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const noPhoto = (!hasSPGlobal || spgError) && mtPhoto === null;
|
||||
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
|
||||
{hasSignalBatch && (
|
||||
{hasSPGlobal && (
|
||||
<div
|
||||
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
||||
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
||||
activeTab === 'spglobal' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
||||
}`}
|
||||
onClick={() => setActiveTab('signal-batch')}
|
||||
onClick={() => setActiveTab('spglobal')}
|
||||
>
|
||||
signal-batch
|
||||
S&P Global
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@ -208,12 +215,26 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt="Vessel"
|
||||
className="w-full rounded block"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
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 p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
|
||||
: null
|
||||
? <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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
256
frontend/src/hooks/useIranData.ts
Normal file
256
frontend/src/hooks/useIranData.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { fetchEvents, fetchSensorData } 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 { propagateAircraft, propagateShips } from '../services/propagation';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types';
|
||||
|
||||
interface UseIranDataArgs {
|
||||
appMode: AppMode;
|
||||
currentTime: number;
|
||||
isLive: boolean;
|
||||
hiddenAcCategories: Set<string>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
refreshKey: number;
|
||||
dashboardTab: 'iran' | 'korea';
|
||||
}
|
||||
|
||||
interface UseIranDataResult {
|
||||
aircraft: Aircraft[];
|
||||
ships: Ship[];
|
||||
visibleAircraft: Aircraft[];
|
||||
visibleShips: Ship[];
|
||||
satPositions: SatellitePosition[];
|
||||
events: GeoEvent[];
|
||||
mergedEvents: GeoEvent[];
|
||||
sensorData: SensorLog[];
|
||||
osintFeed: OsintItem[];
|
||||
aircraftByCategory: Record<string, number>;
|
||||
militaryCount: number;
|
||||
shipsByCategory: Record<string, number>;
|
||||
koreanShips: Ship[];
|
||||
koreanShipsByCategory: Record<string, number>;
|
||||
}
|
||||
|
||||
export function useIranData({
|
||||
appMode,
|
||||
currentTime,
|
||||
isLive,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
refreshKey,
|
||||
dashboardTab,
|
||||
}: UseIranDataArgs): UseIranDataResult {
|
||||
const [events, setEvents] = useState<GeoEvent[]>([]);
|
||||
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
||||
const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]);
|
||||
const [baseShips, setBaseShips] = useState<Ship[]>([]);
|
||||
const [satellites, setSatellites] = useState<Satellite[]>([]);
|
||||
const [satPositions, setSatPositions] = useState<SatellitePosition[]>([]);
|
||||
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
|
||||
|
||||
const satTimeRef = useRef(0);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchEvents().then(setEvents).catch(() => {});
|
||||
fetchSensorData().then(setSensorData).catch(() => {});
|
||||
fetchSatelliteTLE().then(setSatellites).catch(() => {});
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch base aircraft data (LIVE: backend, REPLAY: sample)
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (appMode === 'live') {
|
||||
const result = await fetchAircraftFromBackend('iran');
|
||||
if (result.length > 0) setBaseAircraft(result);
|
||||
} else {
|
||||
setBaseAircraft(getSampleAircraft());
|
||||
}
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [appMode, refreshKey]);
|
||||
|
||||
// Fetch Iran ship data (signal-batch + sample military, 5-min cycle)
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchShips();
|
||||
if (data.length > 0) {
|
||||
setBaseShips(data);
|
||||
}
|
||||
} catch {
|
||||
// keep previous data
|
||||
}
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 300_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [appMode, refreshKey]);
|
||||
|
||||
// Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab)
|
||||
useEffect(() => {
|
||||
const shouldFetch = isLive || dashboardTab === 'iran';
|
||||
if (!shouldFetch) { setOsintFeed([]); return; }
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchOsintFeed('iran');
|
||||
if (data.length > 0) setOsintFeed(data);
|
||||
} catch { /* keep previous */ }
|
||||
};
|
||||
setOsintFeed([]);
|
||||
load();
|
||||
const interval = setInterval(load, 120_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLive, dashboardTab, refreshKey]);
|
||||
|
||||
// Propagate satellite positions — throttle to every 2s of real time
|
||||
useEffect(() => {
|
||||
if (satellites.length === 0) return;
|
||||
const now = Date.now();
|
||||
if (now - satTimeRef.current < 2000) return;
|
||||
satTimeRef.current = now;
|
||||
const positions = propagateAll(satellites, new Date(currentTime));
|
||||
setSatPositions(positions);
|
||||
}, [satellites, currentTime]);
|
||||
|
||||
// Propagate aircraft positions based on current time
|
||||
const aircraft = useMemo(
|
||||
() => propagateAircraft(baseAircraft, currentTime),
|
||||
[baseAircraft, currentTime],
|
||||
);
|
||||
|
||||
// Propagate ship positions based on current time
|
||||
const ships = useMemo(
|
||||
() => propagateShips(baseShips, currentTime, isLive),
|
||||
[baseShips, currentTime, isLive],
|
||||
);
|
||||
|
||||
// Category-filtered data for map rendering
|
||||
const visibleAircraft = useMemo(
|
||||
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||
[aircraft, hiddenAcCategories],
|
||||
);
|
||||
|
||||
const visibleShips = useMemo(
|
||||
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||
[ships, hiddenShipCategories],
|
||||
);
|
||||
|
||||
// OSINT → GeoEvent 변환
|
||||
const osintEvents = useMemo((): GeoEvent[] => {
|
||||
if (dashboardTab !== 'iran' || osintFeed.length === 0) return [];
|
||||
|
||||
const STRIKE_PATTERN = /strike|attack|bomb|airstrike|hit|destroy|blast|공습|타격|폭격|파괴|피격/i;
|
||||
const MISSILE_PATTERN = /missile|launch|drone|발사|미사일|드론/i;
|
||||
const EXPLOSION_PATTERN = /explo|blast|deton|fire|폭발|화재|폭파/i;
|
||||
const INTERCEPT_PATTERN = /intercept|shoot.*down|defense|요격|격추|방어/i;
|
||||
const IMPACT_PATTERN = /impact|hit|struck|damage|casualt|피격|타격|피해|사상/i;
|
||||
|
||||
const categoryToType: Record<string, GeoEvent['type']> = {
|
||||
military: 'osint',
|
||||
shipping: 'osint',
|
||||
oil: 'osint',
|
||||
nuclear: 'osint',
|
||||
diplomacy: 'osint',
|
||||
};
|
||||
|
||||
return osintFeed
|
||||
.filter(item => {
|
||||
if (!item.lat || !item.lng) return false;
|
||||
return item.category in categoryToType;
|
||||
})
|
||||
.map((item): GeoEvent => {
|
||||
let eventType: GeoEvent['type'] = 'osint';
|
||||
const title = item.title;
|
||||
if (IMPACT_PATTERN.test(title)) eventType = 'impact';
|
||||
else if (STRIKE_PATTERN.test(title)) eventType = 'airstrike';
|
||||
else if (MISSILE_PATTERN.test(title)) eventType = 'missile_launch';
|
||||
else if (EXPLOSION_PATTERN.test(title)) eventType = 'explosion';
|
||||
else if (INTERCEPT_PATTERN.test(title)) eventType = 'intercept';
|
||||
|
||||
let source: GeoEvent['source'] | undefined;
|
||||
if (/US|미국|America|Pentagon|CENTCOM/i.test(title)) source = 'US';
|
||||
else if (/Israel|이스라엘|IAF|IDF/i.test(title)) source = 'IL';
|
||||
else if (/Iran|이란|IRGC/i.test(title)) source = 'IR';
|
||||
else if (/Houthi|후티|Hezbollah|헤즈볼라|PMF|proxy|대리/i.test(title)) source = 'proxy';
|
||||
|
||||
return {
|
||||
id: `osint-${item.id}`,
|
||||
timestamp: item.timestamp,
|
||||
lat: item.lat!,
|
||||
lng: item.lng!,
|
||||
type: eventType,
|
||||
source,
|
||||
label: `[OSINT] ${item.title}`,
|
||||
description: `출처: ${item.source} | ${item.url}`,
|
||||
intensity: eventType === 'impact' ? 80 : eventType === 'airstrike' ? 70 : 50,
|
||||
};
|
||||
});
|
||||
}, [osintFeed, dashboardTab]);
|
||||
|
||||
// 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬)
|
||||
const mergedEvents = useMemo(() => {
|
||||
if (osintEvents.length === 0) return events;
|
||||
return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [events, osintEvents]);
|
||||
|
||||
// Aircraft stats
|
||||
const aircraftByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const ac of aircraft) {
|
||||
counts[ac.category] = (counts[ac.category] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [aircraft]);
|
||||
|
||||
const militaryCount = useMemo(
|
||||
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||
[aircraft],
|
||||
);
|
||||
|
||||
// Ship stats — MT classification
|
||||
const shipsByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
|
||||
// Korean ship stats
|
||||
const koreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]);
|
||||
const koreanShipsByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of koreanShips) {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [koreanShips]);
|
||||
|
||||
return {
|
||||
aircraft,
|
||||
ships,
|
||||
visibleAircraft,
|
||||
visibleShips,
|
||||
satPositions,
|
||||
events,
|
||||
mergedEvents,
|
||||
sensorData,
|
||||
osintFeed,
|
||||
aircraftByCategory,
|
||||
militaryCount,
|
||||
shipsByCategory,
|
||||
koreanShips,
|
||||
koreanShipsByCategory,
|
||||
};
|
||||
}
|
||||
160
frontend/src/hooks/useKoreaData.ts
Normal file
160
frontend/src/hooks/useKoreaData.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { fetchAircraftFromBackend } from '../services/aircraftApi';
|
||||
import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak';
|
||||
import { fetchShipsKorea } from '../services/ships';
|
||||
import { fetchOsintFeed } from '../services/osint';
|
||||
import type { OsintItem } from '../services/osint';
|
||||
import { propagateAircraft, propagateShips } from '../services/propagation';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import type { Aircraft, Ship, Satellite, SatellitePosition } from '../types';
|
||||
|
||||
interface UseKoreaDataArgs {
|
||||
currentTime: number;
|
||||
isLive: boolean;
|
||||
hiddenAcCategories: Set<string>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
interface UseKoreaDataResult {
|
||||
aircraft: Aircraft[];
|
||||
ships: Ship[];
|
||||
visibleAircraft: Aircraft[];
|
||||
visibleShips: Ship[];
|
||||
satPositions: SatellitePosition[];
|
||||
osintFeed: OsintItem[];
|
||||
koreaKoreanShips: Ship[];
|
||||
koreaChineseShips: Ship[];
|
||||
shipsByCategory: Record<string, number>;
|
||||
aircraftByCategory: Record<string, number>;
|
||||
militaryCount: number;
|
||||
}
|
||||
|
||||
export function useKoreaData({
|
||||
currentTime,
|
||||
isLive,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
refreshKey,
|
||||
}: UseKoreaDataArgs): UseKoreaDataResult {
|
||||
const [baseAircraftKorea, setBaseAircraftKorea] = useState<Aircraft[]>([]);
|
||||
const [baseShipsKorea, setBaseShipsKorea] = useState<Ship[]>([]);
|
||||
const [satellitesKorea, setSatellitesKorea] = useState<Satellite[]>([]);
|
||||
const [satPositionsKorea, setSatPositionsKorea] = useState<SatellitePosition[]>([]);
|
||||
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
|
||||
|
||||
const satTimeKoreaRef = useRef(0);
|
||||
|
||||
// Fetch Korea satellite TLE data
|
||||
useEffect(() => {
|
||||
fetchSatelliteTLEKorea().then(setSatellitesKorea).catch(() => {});
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch Korea aircraft data
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const result = await fetchAircraftFromBackend('korea');
|
||||
if (result.length > 0) setBaseAircraftKorea(result);
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch Korea region ship data (signal-batch, 4-min cycle)
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchShipsKorea();
|
||||
if (data.length > 0) setBaseShipsKorea(data);
|
||||
} catch { /* keep previous */ }
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 240_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch OSINT feed for Korea tab
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchOsintFeed('korea');
|
||||
if (data.length > 0) setOsintFeed(data);
|
||||
} catch { /* keep previous */ }
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 120_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
// Propagate Korea satellite positions
|
||||
useEffect(() => {
|
||||
if (satellitesKorea.length === 0) return;
|
||||
const now = Date.now();
|
||||
if (now - satTimeKoreaRef.current < 2000) return;
|
||||
satTimeKoreaRef.current = now;
|
||||
const positions = propagateAll(satellitesKorea, new Date(currentTime));
|
||||
setSatPositionsKorea(positions);
|
||||
}, [satellitesKorea, currentTime]);
|
||||
|
||||
// Propagate Korea aircraft (live only — no waypoint propagation needed)
|
||||
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
|
||||
|
||||
// Korea region ships
|
||||
const ships = useMemo(
|
||||
() => propagateShips(baseShipsKorea, currentTime, isLive),
|
||||
[baseShipsKorea, currentTime, isLive],
|
||||
);
|
||||
|
||||
// Category-filtered data for map rendering
|
||||
const visibleAircraft = useMemo(
|
||||
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||
[aircraft, hiddenAcCategories],
|
||||
);
|
||||
|
||||
const visibleShips = useMemo(
|
||||
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||
[ships, hiddenShipCategories],
|
||||
);
|
||||
|
||||
// Korea region stats
|
||||
const koreaKoreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]);
|
||||
const koreaChineseShips = useMemo(() => ships.filter(s => s.flag === 'CN'), [ships]);
|
||||
|
||||
const shipsByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
|
||||
// Korea aircraft stats
|
||||
const aircraftByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const ac of aircraft) {
|
||||
counts[ac.category] = (counts[ac.category] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [aircraft]);
|
||||
|
||||
const militaryCount = useMemo(
|
||||
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||
[aircraft],
|
||||
);
|
||||
|
||||
return {
|
||||
aircraft,
|
||||
ships,
|
||||
visibleAircraft,
|
||||
visibleShips,
|
||||
satPositions: satPositionsKorea,
|
||||
osintFeed,
|
||||
koreaKoreanShips,
|
||||
koreaChineseShips,
|
||||
shipsByCategory,
|
||||
aircraftByCategory,
|
||||
militaryCount,
|
||||
};
|
||||
}
|
||||
320
frontend/src/hooks/useKoreaFilters.ts
Normal file
320
frontend/src/hooks/useKoreaFilters.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import type { Ship } from '../types';
|
||||
|
||||
interface KoreaFilters {
|
||||
illegalFishing: boolean;
|
||||
illegalTransship: boolean;
|
||||
darkVessel: boolean;
|
||||
cableWatch: boolean;
|
||||
dokdoWatch: boolean;
|
||||
ferryWatch: boolean;
|
||||
}
|
||||
|
||||
interface DokdoAlert {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
dist: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface UseKoreaFiltersResult {
|
||||
filters: KoreaFilters;
|
||||
setFilter: (key: keyof KoreaFilters, value: boolean) => void;
|
||||
filteredShips: Ship[];
|
||||
transshipSuspects: Set<string>;
|
||||
cableWatchSuspects: Set<string>;
|
||||
dokdoWatchSuspects: Set<string>;
|
||||
dokdoAlerts: DokdoAlert[];
|
||||
anyFilterOn: boolean;
|
||||
}
|
||||
|
||||
const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
|
||||
const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
||||
const TERRITORIAL_DEG = 0.2; // ~22km (12해리)
|
||||
const ALERT_DEG = 0.4; // ~44km
|
||||
|
||||
export function useKoreaFilters(
|
||||
koreaShips: Ship[],
|
||||
visibleShips: Ship[],
|
||||
currentTime: number,
|
||||
): UseKoreaFiltersResult {
|
||||
const [filters, setFilters] = useState<KoreaFilters>({
|
||||
illegalFishing: false,
|
||||
illegalTransship: false,
|
||||
darkVessel: false,
|
||||
cableWatch: false,
|
||||
dokdoWatch: false,
|
||||
ferryWatch: false,
|
||||
});
|
||||
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
|
||||
|
||||
const proximityStartRef = useRef<Map<string, number>>(new Map());
|
||||
const aisHistoryRef = useRef<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
|
||||
const cableNearStartRef = useRef<Map<string, number>>(new Map());
|
||||
const dokdoAlertedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setFilter = (key: keyof KoreaFilters, value: boolean) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const anyFilterOn =
|
||||
filters.illegalFishing ||
|
||||
filters.illegalTransship ||
|
||||
filters.darkVessel ||
|
||||
filters.cableWatch ||
|
||||
filters.dokdoWatch ||
|
||||
filters.ferryWatch;
|
||||
|
||||
// 불법환적 의심 선박 탐지
|
||||
const transshipSuspects = useMemo(() => {
|
||||
if (!filters.illegalTransship) return new Set<string>();
|
||||
|
||||
const suspects = new Set<string>();
|
||||
const isOffshore = (s: Ship) => {
|
||||
const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5;
|
||||
if (nearCoastWest) {
|
||||
const distFromEastCoast = s.lng - 129.5;
|
||||
const distFromWestCoast = 126.0 - s.lng;
|
||||
const distFromSouthCoast = 34.5 - s.lat;
|
||||
if (distFromEastCoast > 0.15 || distFromWestCoast > 0.15 || distFromSouthCoast > 0.15) return true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isNearForeignCoast = (s: Ship) => {
|
||||
if (s.lng < 123.5 && s.lat > 25 && s.lat < 40) return true;
|
||||
if (s.lng > 130.5 && s.lat > 30 && s.lat < 46) return true;
|
||||
if (s.lng > 129.1 && s.lng < 129.6 && s.lat > 34.0 && s.lat < 34.8) return true;
|
||||
if (s.lng > 129.5 && s.lat > 31 && s.lat < 34) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const candidates = koreaShips.filter(s => {
|
||||
if (s.speed >= 2) return false;
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false;
|
||||
if (isNearForeignCoast(s)) return false;
|
||||
return isOffshore(s);
|
||||
});
|
||||
|
||||
const now = currentTime;
|
||||
const prevMap = proximityStartRef.current;
|
||||
const currentPairs = new Set<string>();
|
||||
const PROXIMITY_DEG = 0.001; // ~110m
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
for (let j = i + 1; j < candidates.length; j++) {
|
||||
const a = candidates[i];
|
||||
const b = candidates[j];
|
||||
const dlat = Math.abs(a.lat - b.lat);
|
||||
const dlng = Math.abs(a.lng - b.lng) * Math.cos((a.lat * Math.PI) / 180);
|
||||
if (dlat < PROXIMITY_DEG && dlng < PROXIMITY_DEG) {
|
||||
const pairKey = [a.mmsi, b.mmsi].sort().join(':');
|
||||
currentPairs.add(pairKey);
|
||||
if (!prevMap.has(pairKey)) {
|
||||
prevMap.set(pairKey, now);
|
||||
}
|
||||
const pairStartTime = prevMap.get(pairKey)!;
|
||||
if (now - pairStartTime >= TRANSSHIP_DURATION_MS) {
|
||||
suspects.add(a.mmsi);
|
||||
suspects.add(b.mmsi);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of prevMap.keys()) {
|
||||
if (!currentPairs.has(key)) prevMap.delete(key);
|
||||
}
|
||||
|
||||
return suspects;
|
||||
}, [koreaShips, filters.illegalTransship, currentTime]);
|
||||
|
||||
// 다크베셀 탐지: AIS 신호 이력 추적
|
||||
const darkVesselSet = useMemo(() => {
|
||||
if (!filters.darkVessel) return new Set<string>();
|
||||
|
||||
const now = currentTime;
|
||||
const history = aisHistoryRef.current;
|
||||
const result = new Set<string>();
|
||||
const currentMmsis = new Set(koreaShips.map(s => s.mmsi));
|
||||
|
||||
for (const s of koreaShips) {
|
||||
let h = history.get(s.mmsi);
|
||||
if (!h) {
|
||||
h = { seen: [], lastGapStart: null };
|
||||
history.set(s.mmsi, h);
|
||||
}
|
||||
if (h.lastGapStart !== null) {
|
||||
const gapDuration = now - h.lastGapStart;
|
||||
if (gapDuration >= ONE_HOUR_MS) {
|
||||
result.add(s.mmsi);
|
||||
}
|
||||
h.lastGapStart = null;
|
||||
}
|
||||
h.seen.push(now);
|
||||
if (h.seen.length > 20) h.seen = h.seen.slice(-20);
|
||||
|
||||
const aisAge = now - s.lastSeen;
|
||||
if (aisAge > ONE_HOUR_MS) {
|
||||
result.add(s.mmsi);
|
||||
}
|
||||
|
||||
if (h.seen.length >= 4) {
|
||||
let gapCount = 0;
|
||||
for (let k = 1; k < h.seen.length; k++) {
|
||||
const gap = h.seen[k] - h.seen[k - 1];
|
||||
if (gap > 150_000) gapCount++;
|
||||
}
|
||||
if (gapCount >= 3) {
|
||||
result.add(s.mmsi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [mmsi, h] of history.entries()) {
|
||||
if (!currentMmsis.has(mmsi) && h.lastGapStart === null) {
|
||||
h.lastGapStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
const SIX_HOURS = 6 * ONE_HOUR_MS;
|
||||
for (const [mmsi, h] of history.entries()) {
|
||||
if (h.seen.length > 0 && now - h.seen[h.seen.length - 1] > SIX_HOURS && !currentMmsis.has(mmsi)) {
|
||||
history.delete(mmsi);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.darkVessel, currentTime]);
|
||||
|
||||
// 해저케이블 감시
|
||||
const cableWatchSet = useMemo(() => {
|
||||
if (!filters.cableWatch) return new Set<string>();
|
||||
const result = new Set<string>();
|
||||
const CABLE_PROX_DEG = 0.01; // ~1.1km
|
||||
|
||||
const segments: [number, number, number, number][] = [];
|
||||
for (const cable of KOREA_SUBMARINE_CABLES) {
|
||||
for (let k = 0; k < cable.route.length - 1; k++) {
|
||||
segments.push([cable.route[k][0], cable.route[k][1], cable.route[k + 1][0], cable.route[k + 1][1]]);
|
||||
}
|
||||
}
|
||||
|
||||
const distToSegment = (px: number, py: number, x1: number, y1: number, x2: number, y2: number) => {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1);
|
||||
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)));
|
||||
const cx = x1 + t * dx;
|
||||
const cy = y1 + t * dy;
|
||||
const dlng = (px - cx) * Math.cos((py * Math.PI) / 180);
|
||||
return Math.hypot(dlng, py - cy);
|
||||
};
|
||||
|
||||
const now = currentTime;
|
||||
const prevMap = cableNearStartRef.current;
|
||||
const currentNear = new Set<string>();
|
||||
|
||||
for (const s of koreaShips) {
|
||||
if (s.speed > 0.6) continue;
|
||||
let nearCable = false;
|
||||
for (const [x1, y1, x2, y2] of segments) {
|
||||
if (distToSegment(s.lng, s.lat, x1, y1, x2, y2) < CABLE_PROX_DEG) {
|
||||
nearCable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nearCable) continue;
|
||||
|
||||
currentNear.add(s.mmsi);
|
||||
if (!prevMap.has(s.mmsi)) {
|
||||
prevMap.set(s.mmsi, now);
|
||||
}
|
||||
const cableStartTime = prevMap.get(s.mmsi)!;
|
||||
if (now - cableStartTime >= CABLE_DURATION_MS) {
|
||||
result.add(s.mmsi);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mmsi of prevMap.keys()) {
|
||||
if (!currentNear.has(mmsi)) prevMap.delete(mmsi);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.cableWatch, currentTime]);
|
||||
|
||||
// 독도감시
|
||||
const dokdoWatchSet = useMemo(() => {
|
||||
if (!filters.dokdoWatch) return new Set<string>();
|
||||
const result = new Set<string>();
|
||||
const newAlerts: DokdoAlert[] = [];
|
||||
const alerted = dokdoAlertedRef.current;
|
||||
|
||||
for (const s of koreaShips) {
|
||||
if (s.flag !== 'JP') continue;
|
||||
const dDokdo = Math.hypot(
|
||||
(s.lng - DOKDO.lng) * Math.cos((DOKDO.lat * Math.PI) / 180),
|
||||
s.lat - DOKDO.lat,
|
||||
);
|
||||
if (dDokdo < TERRITORIAL_DEG) {
|
||||
result.add(s.mmsi);
|
||||
if (!alerted.has(s.mmsi)) {
|
||||
alerted.add(s.mmsi);
|
||||
const distKm = Math.round(dDokdo * 111);
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
|
||||
}
|
||||
} else if (dDokdo < ALERT_DEG) {
|
||||
result.add(s.mmsi);
|
||||
if (!alerted.has(`warn-${s.mmsi}`)) {
|
||||
alerted.add(`warn-${s.mmsi}`);
|
||||
const distKm = Math.round(dDokdo * 111);
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentJP = new Set(koreaShips.filter(s => s.flag === 'JP').map(s => s.mmsi));
|
||||
for (const key of alerted) {
|
||||
const mmsi = key.replace('warn-', '');
|
||||
if (!currentJP.has(mmsi)) alerted.delete(key);
|
||||
}
|
||||
|
||||
if (newAlerts.length > 0) {
|
||||
setDokdoAlerts(prev => [...newAlerts, ...prev].slice(0, 10));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.dokdoWatch, currentTime]);
|
||||
|
||||
// 필터링된 선박 목록
|
||||
const filteredShips = useMemo(() => {
|
||||
if (!anyFilterOn) return visibleShips;
|
||||
return visibleShips.filter(s => {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
||||
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
||||
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
||||
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
|
||||
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
|
||||
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
||||
return false;
|
||||
});
|
||||
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilter,
|
||||
filteredShips,
|
||||
transshipSuspects,
|
||||
cableWatchSuspects: cableWatchSet,
|
||||
dokdoWatchSuspects: dokdoWatchSet,
|
||||
dokdoAlerts,
|
||||
anyFilterOn,
|
||||
};
|
||||
}
|
||||
@ -9,14 +9,14 @@ const defaultConfig: ApiConfig = {
|
||||
|
||||
let cachedSensorData: SensorLog[] | null = null;
|
||||
|
||||
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.eventsEndpoint);
|
||||
// return res.json();
|
||||
return Promise.resolve(sampleEvents);
|
||||
}
|
||||
|
||||
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.sensorEndpoint);
|
||||
// return res.json();
|
||||
|
||||
@ -12,17 +12,6 @@ export interface PowerFacility {
|
||||
voltage?: string; // for substations
|
||||
}
|
||||
|
||||
// Overpass QL: power plants + wind generators + substations in South Korea
|
||||
const OVERPASS_QUERY = `
|
||||
[out:json][timeout:30][bbox:33,124,39,132];
|
||||
(
|
||||
nwr["power"="plant"];
|
||||
nwr["power"="generator"]["generator:source"="wind"];
|
||||
nwr["power"="substation"]["substation"="transmission"];
|
||||
);
|
||||
out center 500;
|
||||
`;
|
||||
|
||||
let cachedData: PowerFacility[] | null = null;
|
||||
let lastFetch = 0;
|
||||
const CACHE_MS = 600_000; // 10 min cache
|
||||
@ -30,67 +19,10 @@ const CACHE_MS = 600_000; // 10 min cache
|
||||
export async function fetchKoreaInfra(): Promise<PowerFacility[]> {
|
||||
if (cachedData && Date.now() - lastFetch < CACHE_MS) return cachedData;
|
||||
|
||||
try {
|
||||
const url = `/api/overpass/api/interpreter`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(OVERPASS_QUERY)}`,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status}`);
|
||||
const json = await res.json();
|
||||
|
||||
const facilities: PowerFacility[] = [];
|
||||
|
||||
for (const el of json.elements || []) {
|
||||
const tags = el.tags || {};
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
|
||||
const isPower = tags.power;
|
||||
if (isPower === 'plant') {
|
||||
facilities.push({
|
||||
id: `plant-${el.id}`,
|
||||
type: 'plant',
|
||||
name: tags.name || tags['name:ko'] || tags['name:en'] || 'Power Plant',
|
||||
lat, lng,
|
||||
source: tags['plant:source'] || tags['generator:source'] || undefined,
|
||||
output: tags['plant:output:electricity'] || undefined,
|
||||
operator: tags.operator || undefined,
|
||||
});
|
||||
} else if (isPower === 'generator' && tags['generator:source'] === 'wind') {
|
||||
facilities.push({
|
||||
id: `wind-${el.id}`,
|
||||
type: 'plant',
|
||||
name: tags.name || tags['name:ko'] || tags['name:en'] || '풍력발전기',
|
||||
lat, lng,
|
||||
source: 'wind',
|
||||
output: tags['generator:output:electricity'] || undefined,
|
||||
operator: tags.operator || undefined,
|
||||
});
|
||||
} else if (isPower === 'substation') {
|
||||
facilities.push({
|
||||
id: `sub-${el.id}`,
|
||||
type: 'substation',
|
||||
name: tags.name || tags['name:ko'] || tags['name:en'] || 'Substation',
|
||||
lat, lng,
|
||||
voltage: tags.voltage || undefined,
|
||||
operator: tags.operator || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Overpass: ${facilities.length} power facilities in Korea (${facilities.filter(f => f.type === 'plant').length} plants, ${facilities.filter(f => f.type === 'substation').length} substations)`);
|
||||
cachedData = facilities;
|
||||
// 정적 데이터 사용 (Overpass API는 프로덕션 nginx에서 미지원 + fallback 데이터로 충분)
|
||||
cachedData = getFallbackInfra();
|
||||
lastFetch = Date.now();
|
||||
return facilities;
|
||||
} catch (err) {
|
||||
console.warn('Overpass API failed, using fallback data:', err);
|
||||
if (cachedData) return cachedData;
|
||||
return getFallbackInfra();
|
||||
}
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// Fallback: major Korean power plants (in case API fails)
|
||||
|
||||
47
frontend/src/utils/marineTraffic.ts
Normal file
47
frontend/src/utils/marineTraffic.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// MarineTraffic-style ship classification
|
||||
// Maps S&P STAT5CODE prefixes and our custom typecodes to MT categories
|
||||
export function getMarineTrafficCategory(typecode?: string, category?: string): string {
|
||||
if (!typecode) {
|
||||
// Fallback to our internal category
|
||||
if (category === 'tanker') return 'tanker';
|
||||
if (category === 'cargo') return 'cargo';
|
||||
if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military';
|
||||
return 'unspecified';
|
||||
}
|
||||
const code = typecode.toUpperCase();
|
||||
|
||||
// Our custom typecodes
|
||||
if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker';
|
||||
if (code === 'CONT' || code === 'BULK') return 'cargo';
|
||||
if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military';
|
||||
|
||||
// S&P STAT5CODE (IHS StatCode5) — first 2 chars determine main category
|
||||
// A1x = Tankers (crude, products, chemical, LPG, LNG)
|
||||
if (code.startsWith('A1')) return 'tanker';
|
||||
// A2x = Bulk carriers
|
||||
if (code.startsWith('A2')) return 'cargo';
|
||||
// A3x = General cargo / Container / Reefer / Ro-Ro
|
||||
if (code.startsWith('A3')) return 'cargo';
|
||||
// B1x / B2x = Passenger / Cruise / Ferry
|
||||
if (code.startsWith('B')) return 'passenger';
|
||||
// C1x = Fishing
|
||||
if (code.startsWith('C')) return 'fishing';
|
||||
// D1x = Offshore (tugs, supply, etc.)
|
||||
if (code.startsWith('D')) return 'tug_special';
|
||||
// E = Other activities (research, cable layers, dredgers)
|
||||
if (code.startsWith('E')) return 'tug_special';
|
||||
// X = Non-propelled (barges)
|
||||
if (code.startsWith('X')) return 'unspecified';
|
||||
|
||||
// S&P VesselType strings
|
||||
const lower = code.toLowerCase();
|
||||
if (lower.includes('tanker')) return 'tanker';
|
||||
if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk')) return 'cargo';
|
||||
if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'passenger';
|
||||
if (lower.includes('fishing')) return 'fishing';
|
||||
if (lower.includes('tug') || lower.includes('supply') || lower.includes('offshore')) return 'tug_special';
|
||||
if (lower.includes('high speed')) return 'high_speed';
|
||||
if (lower.includes('pleasure') || lower.includes('yacht') || lower.includes('sailing')) return 'pleasure';
|
||||
|
||||
return 'unspecified';
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user