chore: develop 브랜치 동기화 — 충돌 해결
This commit is contained in:
커밋
409e618a39
@ -53,6 +53,8 @@ jobs:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
OPENSKY_CLIENT_ID: ${{ secrets.OPENSKY_CLIENT_ID }}
|
||||
OPENSKY_CLIENT_SECRET: ${{ secrets.OPENSKY_CLIENT_SECRET }}
|
||||
run: |
|
||||
DEPLOY_DIR=/deploy/kcg-backend
|
||||
mkdir -p $DEPLOY_DIR/backup
|
||||
@ -68,6 +70,8 @@ jobs:
|
||||
[ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$OPENSKY_CLIENT_ID" ] && echo "OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$OPENSKY_CLIENT_SECRET" ] && echo "OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}" >> $DEPLOY_DIR/.env
|
||||
echo "PREDICTION_BASE_URL=http://192.168.1.18:8001" >> $DEPLOY_DIR/.env
|
||||
|
||||
# JAR 내부에 application-prod.yml이 있으면 외부 파일 제거
|
||||
@ -172,50 +176,47 @@ jobs:
|
||||
# systemd 서비스 파일 전송
|
||||
scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service
|
||||
|
||||
# 원격 설치 + 재시작
|
||||
for attempt in 1 2 3; do
|
||||
echo "SSH deploy attempt $attempt/3..."
|
||||
if ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
|
||||
set -e
|
||||
REMOTE_DIR=/home/apps/kcg-prediction
|
||||
mkdir -p $REMOTE_DIR
|
||||
cd $REMOTE_DIR
|
||||
# 원격 설치 + 재시작 (단일 SSH — tar.gz는 SCP에서 이미 전송됨)
|
||||
ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
|
||||
set -e
|
||||
REMOTE_DIR=/home/apps/kcg-prediction
|
||||
mkdir -p $REMOTE_DIR
|
||||
cd $REMOTE_DIR
|
||||
|
||||
# 코드 배포
|
||||
tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR
|
||||
rm -f /tmp/prediction.tar.gz
|
||||
# 코드 배포
|
||||
tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR
|
||||
|
||||
# venv + 의존성
|
||||
python3 -m venv venv 2>/dev/null || true
|
||||
venv/bin/pip install -r requirements.txt -q
|
||||
# venv + 의존성
|
||||
python3 -m venv venv 2>/dev/null || true
|
||||
venv/bin/pip install -r requirements.txt -q
|
||||
|
||||
# systemd 서비스 갱신
|
||||
if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then
|
||||
cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable kcg-prediction
|
||||
# SELinux 컨텍스트 (Rocky Linux)
|
||||
chcon -R -t bin_t venv/bin/ 2>/dev/null || true
|
||||
|
||||
# systemd 서비스 갱신
|
||||
if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then
|
||||
cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable kcg-prediction
|
||||
fi
|
||||
|
||||
# 재시작
|
||||
systemctl restart kcg-prediction
|
||||
|
||||
# health 확인 (60초 — 초기 로드에 ~30초 소요)
|
||||
for i in $(seq 1 12); do
|
||||
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
|
||||
echo "Prediction healthy (attempt ${i})"
|
||||
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
|
||||
exit 0
|
||||
fi
|
||||
rm -f /tmp/kcg-prediction.service
|
||||
|
||||
# 재시작
|
||||
systemctl restart kcg-prediction
|
||||
|
||||
# health 확인 (30초)
|
||||
for i in $(seq 1 6); do
|
||||
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
|
||||
echo "Prediction healthy (${i})"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "WARNING: Prediction health timeout"
|
||||
journalctl -u kcg-prediction --no-pager -n 10
|
||||
exit 1
|
||||
sleep 5
|
||||
done
|
||||
echo "WARNING: Prediction health timeout (서비스는 시작됨, 초기 로드 진행 중)"
|
||||
systemctl is-active kcg-prediction && echo "Service is active"
|
||||
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
|
||||
SCRIPT
|
||||
then exit 0; fi
|
||||
[ "$attempt" -eq 3 ] && { echo "ERROR: SSH failed"; exit 1; }
|
||||
sleep 10
|
||||
done
|
||||
echo "Prediction deployment completed"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
|
||||
@ -25,6 +25,7 @@ public class AuthFilter extends OncePerRequestFilter {
|
||||
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
|
||||
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
|
||||
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
|
||||
private static final String FLEET_PATH_PREFIX = "/api/fleet-";
|
||||
|
||||
private final JwtProvider jwtProvider;
|
||||
|
||||
@ -35,7 +36,8 @@ public class AuthFilter extends OncePerRequestFilter {
|
||||
|| path.startsWith(SENSOR_PATH_PREFIX)
|
||||
|| path.startsWith(CCTV_PATH_PREFIX)
|
||||
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|
||||
|| path.startsWith(PREDICTION_PATH_PREFIX);
|
||||
|| path.startsWith(PREDICTION_PATH_PREFIX)
|
||||
|| path.startsWith(FLEET_PATH_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -3,6 +3,7 @@ package gc.mda.kcg.collector.aircraft;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
||||
@ -11,9 +12,15 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
@ -24,22 +31,23 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class OpenSkyCollector {
|
||||
|
||||
private static final String BASE_URL = "https://opensky-network.org/api";
|
||||
|
||||
// 이란/중동 bbox
|
||||
private static final String IRAN_PARAMS = "lamin=24&lomin=30&lamax=42&lomax=62";
|
||||
// 한국/동아시아 bbox
|
||||
private static final String KOREA_PARAMS = "lamin=20&lomin=115&lamax=45&lomax=145";
|
||||
private static final long TOKEN_REFRESH_MARGIN_SEC = 120;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AircraftCacheStore cacheStore;
|
||||
private final AircraftPositionRepository positionRepository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
|
||||
private String accessToken;
|
||||
private Instant tokenExpiresAt = Instant.EPOCH;
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 300_000)
|
||||
public void collectIran() {
|
||||
List<AircraftDto> aircraft = fetchStates(IRAN_PARAMS);
|
||||
if (!aircraft.isEmpty()) {
|
||||
@ -48,12 +56,12 @@ public class OpenSkyCollector {
|
||||
persistAll(aircraft, "opensky", "iran");
|
||||
tracker.recordSuccess("opensky-iran", "iran", aircraft.size());
|
||||
} else {
|
||||
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429");
|
||||
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 인증 실패");
|
||||
}
|
||||
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
|
||||
@Scheduled(initialDelay = 180_000, fixedDelay = 300_000)
|
||||
public void collectKorea() {
|
||||
List<AircraftDto> aircraft = fetchStates(KOREA_PARAMS);
|
||||
if (!aircraft.isEmpty()) {
|
||||
@ -62,15 +70,23 @@ public class OpenSkyCollector {
|
||||
persistAll(aircraft, "opensky", "korea");
|
||||
tracker.recordSuccess("opensky-korea", "korea", aircraft.size());
|
||||
} else {
|
||||
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429");
|
||||
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 인증 실패");
|
||||
}
|
||||
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
|
||||
}
|
||||
|
||||
private List<AircraftDto> fetchStates(String bboxParams) {
|
||||
try {
|
||||
String url = BASE_URL + "/states/all?" + bboxParams;
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String token = getAccessToken();
|
||||
String url = appProperties.getCollector().getOpenSkyBaseUrl() + "/states/all?" + bboxParams;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (token != null) {
|
||||
headers.setBearerAuth(token);
|
||||
}
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
||||
if (response.getStatusCode().value() == 429) {
|
||||
log.warn("OpenSky 429 rate limited, 스킵");
|
||||
return List.of();
|
||||
@ -83,6 +99,47 @@ public class OpenSkyCollector {
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
if (accessToken != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(TOKEN_REFRESH_MARGIN_SEC))) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
String clientId = appProperties.getCollector().getOpenSkyClientId();
|
||||
String clientSecret = appProperties.getCollector().getOpenSkyClientSecret();
|
||||
if (clientId == null || clientSecret == null) {
|
||||
log.debug("OpenSky OAuth2 미설정, 익명 모드로 동작");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String authUrl = appProperties.getCollector().getOpenSkyAuthUrl();
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
body.add("grant_type", "client_credentials");
|
||||
body.add("client_id", clientId);
|
||||
body.add("client_secret", clientSecret);
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(authUrl, request, String.class);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.getBody());
|
||||
accessToken = json.get("access_token").asText();
|
||||
int expiresIn = json.get("expires_in").asInt();
|
||||
tokenExpiresAt = Instant.now().plusSeconds(expiresIn);
|
||||
|
||||
log.info("OpenSky OAuth2 토큰 발급 완료 (만료: {}초)", expiresIn);
|
||||
return accessToken;
|
||||
} catch (Exception e) {
|
||||
log.warn("OpenSky OAuth2 토큰 발급 실패: {}, 익명 모드로 폴백", e.getMessage());
|
||||
accessToken = null;
|
||||
tokenExpiresAt = Instant.EPOCH;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAll(List<AircraftDto> aircraft, String source, String region) {
|
||||
try {
|
||||
Instant now = Instant.now();
|
||||
|
||||
@ -118,8 +118,7 @@ public class OsintCollector {
|
||||
if (articleUrl == null || title == null || title.isBlank()) continue;
|
||||
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue;
|
||||
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
|
||||
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
|
||||
if (osintFeedRepository.existsByTitle(title)) continue;
|
||||
|
||||
String seendate = article.path("seendate").asText(null);
|
||||
Instant publishedAt = parseGdeltDate(seendate);
|
||||
@ -140,8 +139,12 @@ public class OsintCollector {
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
try {
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
} catch (Exception ex) {
|
||||
log.debug("GDELT 중복 스킵: {}", title);
|
||||
}
|
||||
}
|
||||
log.debug("GDELT {} 저장: {}건", region, saved);
|
||||
return saved;
|
||||
@ -184,8 +187,7 @@ public class OsintCollector {
|
||||
|
||||
if (link == null || title == null || title.isBlank()) continue;
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue;
|
||||
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
|
||||
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
|
||||
if (osintFeedRepository.existsByTitle(title)) continue;
|
||||
|
||||
Instant publishedAt = parseRssDate(pubDate);
|
||||
|
||||
@ -201,8 +203,12 @@ public class OsintCollector {
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
try {
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
} catch (Exception ex) {
|
||||
log.debug("Google News 중복 스킵: {}", title);
|
||||
}
|
||||
}
|
||||
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
||||
return saved;
|
||||
@ -265,6 +271,6 @@ public class OsintCollector {
|
||||
}
|
||||
|
||||
private String encodeQuery(String query) {
|
||||
return query.replace(" ", "+");
|
||||
return java.net.URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,9 @@ public class AppProperties {
|
||||
public static class Collector {
|
||||
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
|
||||
private String openSkyBaseUrl = "https://opensky-network.org/api";
|
||||
private String openSkyAuthUrl = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
||||
private String openSkyClientId;
|
||||
private String openSkyClientSecret;
|
||||
private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc";
|
||||
private String googleNewsBaseUrl = "https://news.google.com/rss/search";
|
||||
private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php";
|
||||
|
||||
@ -58,6 +58,7 @@ public class VesselAnalysisResult {
|
||||
|
||||
private Double spoofingScore;
|
||||
|
||||
@Column(name = "bd09_offset_m")
|
||||
private Double bd09OffsetM;
|
||||
|
||||
private Integer speedJumpCount;
|
||||
|
||||
@ -7,5 +7,8 @@ import java.util.List;
|
||||
|
||||
public interface VesselAnalysisResultRepository extends JpaRepository<VesselAnalysisResult, Long> {
|
||||
|
||||
List<VesselAnalysisResult> findByTimestampAfter(Instant since);
|
||||
List<VesselAnalysisResult> findByAnalyzedAtAfter(Instant since);
|
||||
|
||||
/** 가장 최근 analyzed_at 이후 결과 전체 (최신 분석 사이클) */
|
||||
List<VesselAnalysisResult> findByAnalyzedAtGreaterThanEqual(Instant since);
|
||||
}
|
||||
|
||||
@ -8,19 +8,21 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VesselAnalysisService {
|
||||
|
||||
private static final int RECENT_MINUTES = 10;
|
||||
|
||||
private final VesselAnalysisResultRepository repository;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
/**
|
||||
* 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
|
||||
* 최근 1시간 내 분석 결과를 반환한다. mmsi별 최신 1건만.
|
||||
* Caffeine 캐시(TTL 5분) 적용.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<VesselAnalysisDto> getLatestResults() {
|
||||
@ -32,9 +34,16 @@ public class VesselAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
|
||||
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
|
||||
.stream()
|
||||
Instant since = Instant.now().minus(2, ChronoUnit.HOURS);
|
||||
// mmsi별 최신 analyzed_at 1건만 유지
|
||||
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
|
||||
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
|
||||
latest.merge(r.getMmsi(), r, (old, cur) ->
|
||||
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
|
||||
}
|
||||
|
||||
List<VesselAnalysisDto> results = latest.values().stream()
|
||||
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
||||
.map(VesselAnalysisDto::from)
|
||||
.toList();
|
||||
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/fleet-companies")
|
||||
@RequiredArgsConstructor
|
||||
public class FleetCompanyController {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() {
|
||||
List<Map<String, Object>> results = jdbcTemplate.queryForList(
|
||||
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id"
|
||||
);
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
|
||||
boolean existsBySourceAndSourceUrl(String source, String sourceUrl);
|
||||
|
||||
boolean existsByRegionAndTitleAndCollectedAtAfter(String region, String title, Instant since);
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
}
|
||||
|
||||
@ -11,3 +11,6 @@ app:
|
||||
client-id: YOUR_GOOGLE_CLIENT_ID
|
||||
auth:
|
||||
allowed-domain: gcsc.co.kr
|
||||
collector:
|
||||
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
|
||||
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET
|
||||
|
||||
@ -12,6 +12,8 @@ app:
|
||||
auth:
|
||||
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||
collector:
|
||||
open-sky-client-id: ${OPENSKY_CLIENT_ID:}
|
||||
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
|
||||
prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001}
|
||||
cors:
|
||||
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev
|
||||
|
||||
74
database/migration/007_fleet_registry.sql
Normal file
74
database/migration/007_fleet_registry.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- 선단 등록 + 어망/어구 정체성 추적 시스템
|
||||
|
||||
-- 1. 소유자/회사
|
||||
CREATE TABLE IF NOT EXISTS kcg.fleet_companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name_cn TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. 등록 선박 (906척 참고자료 기반)
|
||||
CREATE TABLE IF NOT EXISTS kcg.fleet_vessels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INT REFERENCES kcg.fleet_companies(id),
|
||||
permit_no VARCHAR(20) NOT NULL,
|
||||
name_cn TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
tonnage INT,
|
||||
gear_code VARCHAR(10),
|
||||
fleet_role VARCHAR(20),
|
||||
pair_vessel_id INT REFERENCES kcg.fleet_vessels(id),
|
||||
mmsi VARCHAR(15),
|
||||
match_confidence REAL DEFAULT 0,
|
||||
match_method VARCHAR(20),
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_mmsi ON kcg.fleet_vessels(mmsi);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_name_en ON kcg.fleet_vessels(name_en);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_name_cn ON kcg.fleet_vessels(name_cn);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_company ON kcg.fleet_vessels(company_id);
|
||||
|
||||
-- 3. 어망/어구 정체성 이력
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_identity_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mmsi VARCHAR(15) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_name TEXT,
|
||||
parent_mmsi VARCHAR(15),
|
||||
parent_vessel_id INT REFERENCES kcg.fleet_vessels(id),
|
||||
gear_index_1 INT,
|
||||
gear_index_2 INT,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
match_method VARCHAR(30),
|
||||
match_confidence REAL DEFAULT 0,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gear_identity_mmsi ON kcg.gear_identity_log(mmsi);
|
||||
CREATE INDEX IF NOT EXISTS idx_gear_identity_parent ON kcg.gear_identity_log(parent_mmsi);
|
||||
CREATE INDEX IF NOT EXISTS idx_gear_identity_active ON kcg.gear_identity_log(is_active) WHERE is_active = TRUE;
|
||||
|
||||
-- 4. 선단 추적 스냅샷 (5분 주기)
|
||||
CREATE TABLE IF NOT EXISTS kcg.fleet_tracking_snapshot (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
company_id INT REFERENCES kcg.fleet_companies(id),
|
||||
snapshot_time TIMESTAMPTZ NOT NULL,
|
||||
total_vessels INT,
|
||||
active_vessels INT,
|
||||
in_zone_vessels INT,
|
||||
operating_vessels INT,
|
||||
gear_count INT,
|
||||
fleet_status VARCHAR(20),
|
||||
center_lat DOUBLE PRECISION,
|
||||
center_lon DOUBLE PRECISION,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_snapshot_time ON kcg.fleet_tracking_snapshot(snapshot_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_snapshot_company ON kcg.fleet_tracking_snapshot(company_id);
|
||||
@ -4,26 +4,67 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-23.2]
|
||||
|
||||
### 추가
|
||||
- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가
|
||||
- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단
|
||||
- 어구/어망 카테고리 신설 + 모선 연결선 시각화
|
||||
- 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본)
|
||||
- 이란 주변국 시설 레이어 (MEFacilityLayer 35개소)
|
||||
- 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영
|
||||
- 한국 해군부대 10개소, 항만, 풍력발전단지, 북한 발사대/미사일 이벤트 레이어
|
||||
- 정부기관 건물 레이어 (GovBuildingLayer)
|
||||
- CCTV 프록시 컨트롤러
|
||||
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
|
||||
- 중국어선감시 탭: 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시
|
||||
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
|
||||
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
|
||||
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
|
||||
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
|
||||
- AI 분석 닫힘 시 위험도 마커 off
|
||||
|
||||
### 변경
|
||||
- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망
|
||||
- 오른쪽 패널 접기/펼치기 기능
|
||||
- 센서차트 기본 숨김
|
||||
- CCTV 레이어 리팩토링
|
||||
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
|
||||
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
|
||||
|
||||
### 수정
|
||||
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
|
||||
|
||||
## [2026-03-23]
|
||||
|
||||
### 추가
|
||||
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
|
||||
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
|
||||
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
|
||||
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
|
||||
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
|
||||
|
||||
### 변경
|
||||
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
|
||||
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
|
||||
|
||||
### 수정
|
||||
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
|
||||
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
|
||||
- deck.gl 레이어 호버 시 pointer 커서 표시
|
||||
- prediction 증분 수집 버그 수정 (vessel_store.py)
|
||||
|
||||
## [2026-03-20]
|
||||
|
||||
### 변경
|
||||
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
|
||||
- 정적 마커 11종 deck.gl 전환 + 줌 레벨별 스케일
|
||||
|
||||
### 추가
|
||||
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
|
||||
- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고
|
||||
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
|
||||
- 불법어선/다크베셀/중국어선감시 Python 분석 연동
|
||||
- Backend vessel-analysis REST API + DB 테이블 복원
|
||||
- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류
|
||||
|
||||
### 수정
|
||||
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
|
||||
|
||||
## [2026-03-19]
|
||||
|
||||
### 추가
|
||||
- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)
|
||||
|
||||
### 변경
|
||||
- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304)
|
||||
- 인라인 CSS 정리 — 공통 클래스 추출 + Tailwind 전환
|
||||
|
||||
### 수정
|
||||
|
||||
344
frontend/package-lock.json
generated
344
frontend/package-lock.json
generated
@ -8,7 +8,12 @@
|
||||
"name": "kcg-monitoring",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.2.11",
|
||||
"@deck.gl/layers": "^9.2.11",
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
@ -291,6 +296,76 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deck.gl/core": {
|
||||
"version": "9.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.11.tgz",
|
||||
"integrity": "sha512-lpdxXQuFSkd6ET7M6QxPI8QMhsLRY6vzLyk83sPGFb7JSb4OhrNHYt9sfIhcA/hxJW7bdBSMWWphf2GvQetVuA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@loaders.gl/core": "~4.3.4",
|
||||
"@loaders.gl/images": "~4.3.4",
|
||||
"@luma.gl/constants": "~9.2.6",
|
||||
"@luma.gl/core": "~9.2.6",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
"@luma.gl/shadertools": "~9.2.6",
|
||||
"@luma.gl/webgl": "~9.2.6",
|
||||
"@math.gl/core": "^4.1.0",
|
||||
"@math.gl/sun": "^4.1.0",
|
||||
"@math.gl/types": "^4.1.0",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@probe.gl/env": "^4.1.1",
|
||||
"@probe.gl/log": "^4.1.1",
|
||||
"@probe.gl/stats": "^4.1.1",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"gl-matrix": "^3.0.0",
|
||||
"mjolnir.js": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deck.gl/layers": {
|
||||
"version": "9.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz",
|
||||
"integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@loaders.gl/images": "~4.3.4",
|
||||
"@loaders.gl/schema": "~4.3.4",
|
||||
"@luma.gl/shadertools": "~9.2.6",
|
||||
"@mapbox/tiny-sdf": "^2.0.5",
|
||||
"@math.gl/core": "^4.1.0",
|
||||
"@math.gl/polygon": "^4.1.0",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"earcut": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deck.gl/core": "~9.2.0",
|
||||
"@loaders.gl/core": "~4.3.4",
|
||||
"@luma.gl/core": "~9.2.6",
|
||||
"@luma.gl/engine": "~9.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@deck.gl/layers/node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@deck.gl/mapbox": {
|
||||
"version": "9.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.11.tgz",
|
||||
"integrity": "sha512-5OaFZgjyA4Vq6WjHUdcEdl0Phi8dwj8hSCErej0NetW90mctdbxwMt0gSbqcvWBowwhyj2QAhH0P2FcITjKG/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@luma.gl/constants": "~9.2.6",
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deck.gl/core": "~9.2.0",
|
||||
"@luma.gl/constants": "~9.2.6",
|
||||
"@luma.gl/core": "~9.2.6",
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
@ -919,6 +994,133 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/core": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz",
|
||||
"integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@loaders.gl/loader-utils": "4.3.4",
|
||||
"@loaders.gl/schema": "4.3.4",
|
||||
"@loaders.gl/worker-utils": "4.3.4",
|
||||
"@probe.gl/log": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/images": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz",
|
||||
"integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@loaders.gl/loader-utils": "4.3.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@loaders.gl/core": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/loader-utils": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz",
|
||||
"integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@loaders.gl/schema": "4.3.4",
|
||||
"@loaders.gl/worker-utils": "4.3.4",
|
||||
"@probe.gl/log": "^4.0.2",
|
||||
"@probe.gl/stats": "^4.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@loaders.gl/core": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/schema": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz",
|
||||
"integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@loaders.gl/core": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@loaders.gl/worker-utils": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz",
|
||||
"integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@loaders.gl/core": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/constants": {
|
||||
"version": "9.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz",
|
||||
"integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@luma.gl/core": {
|
||||
"version": "9.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz",
|
||||
"integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@math.gl/types": "^4.1.0",
|
||||
"@probe.gl/env": "^4.0.8",
|
||||
"@probe.gl/log": "^4.0.8",
|
||||
"@probe.gl/stats": "^4.0.8",
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/engine": {
|
||||
"version": "9.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz",
|
||||
"integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@math.gl/core": "^4.1.0",
|
||||
"@math.gl/types": "^4.1.0",
|
||||
"@probe.gl/log": "^4.0.8",
|
||||
"@probe.gl/stats": "^4.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@luma.gl/core": "~9.2.0",
|
||||
"@luma.gl/shadertools": "~9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/shadertools": {
|
||||
"version": "9.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz",
|
||||
"integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@math.gl/core": "^4.1.0",
|
||||
"@math.gl/types": "^4.1.0",
|
||||
"wgsl_reflect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@luma.gl/core": "~9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/webgl": {
|
||||
"version": "9.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz",
|
||||
"integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@luma.gl/constants": "9.2.6",
|
||||
"@math.gl/types": "^4.1.0",
|
||||
"@probe.gl/env": "^4.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@luma.gl/core": "~9.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"engines": {
|
||||
@ -1002,6 +1204,66 @@
|
||||
"version": "5.0.4",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@math.gl/core": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
|
||||
"integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/types": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@math.gl/polygon": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz",
|
||||
"integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/core": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@math.gl/sun": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz",
|
||||
"integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@math.gl/types": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz",
|
||||
"integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@math.gl/web-mercator": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz",
|
||||
"integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/core": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@probe.gl/env": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz",
|
||||
"integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@probe.gl/log": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz",
|
||||
"integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@probe.gl/env": "4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@probe.gl/stats": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz",
|
||||
"integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"license": "Hippocratic-2.1",
|
||||
@ -1628,6 +1890,49 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/boolean-point-in-polygon": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz",
|
||||
"integrity": "sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/helpers": "7.3.4",
|
||||
"@turf/invariant": "7.3.4",
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"point-in-polygon-hao": "^1.1.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/helpers": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
|
||||
"integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/invariant": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.4.tgz",
|
||||
"integrity": "sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/helpers": "7.3.4",
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
@ -1739,6 +2044,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"devOptional": true,
|
||||
@ -3448,6 +3759,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mjolnir.js": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz",
|
||||
"integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
@ -3579,6 +3896,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/point-in-polygon-hao": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz",
|
||||
"integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"funding": [
|
||||
@ -3805,6 +4131,12 @@
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"license": "MIT",
|
||||
@ -4043,6 +4375,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"dev": true,
|
||||
@ -4262,6 +4600,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wgsl_reflect": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
|
||||
"integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
|
||||
@ -10,7 +10,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.2.11",
|
||||
"@deck.gl/layers": "^9.2.11",
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage';
|
||||
import { ReplayMap } from './components/iran/ReplayMap';
|
||||
import type { FlyToTarget } from './components/iran/ReplayMap';
|
||||
import { GlobeMap } from './components/iran/GlobeMap';
|
||||
@ -15,6 +16,7 @@ import { useMonitor } from './hooks/useMonitor';
|
||||
import { useIranData } from './hooks/useIranData';
|
||||
import { useKoreaData } from './hooks/useKoreaData';
|
||||
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
||||
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
|
||||
import type { GeoEvent, LayerVisibility, AppMode } from './types';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
@ -22,7 +24,21 @@ import { useTranslation } from 'react-i18next';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
||||
import { filterFacilities } from './data/meEnergyHazardFacilities';
|
||||
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
||||
import { EAST_ASIA_PORTS } from './data/ports';
|
||||
import { KOREAN_AIRPORTS } from './services/airports';
|
||||
import { MILITARY_BASES } from './data/militaryBases';
|
||||
import { GOV_BUILDINGS } from './data/govBuildings';
|
||||
import { KOREA_WIND_FARMS } from './data/windFarms';
|
||||
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
|
||||
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
|
||||
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
|
||||
import { NAV_WARNINGS } from './services/navWarning';
|
||||
import { PIRACY_ZONES } from './services/piracy';
|
||||
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
||||
import { HAZARD_FACILITIES } from './data/hazardFacilities';
|
||||
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
|
||||
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@ -53,9 +69,9 @@ interface AuthenticatedAppProps {
|
||||
|
||||
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
|
||||
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
|
||||
const [layers, setLayers] = useState<LayerVisibility>({
|
||||
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
|
||||
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
|
||||
const [layers, setLayers] = useLocalStorage<LayerVisibility>('iranLayers', {
|
||||
events: true,
|
||||
aircraft: true,
|
||||
satellites: true,
|
||||
@ -67,7 +83,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
meFacilities: true,
|
||||
militaryOnly: false,
|
||||
overseasUS: false,
|
||||
overseasIsrael: false,
|
||||
overseasUK: false,
|
||||
overseasIran: false,
|
||||
overseasUAE: false,
|
||||
overseasSaudi: false,
|
||||
@ -79,7 +95,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
});
|
||||
|
||||
// Korea tab layer visibility (lifted from KoreaMap)
|
||||
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
|
||||
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
|
||||
ships: true,
|
||||
aircraft: true,
|
||||
satellites: true,
|
||||
@ -119,11 +135,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
const toggleKoreaLayer = useCallback((key: string) => {
|
||||
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
}, [setKoreaLayers]);
|
||||
|
||||
// Category filter state (shared across tabs)
|
||||
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
|
||||
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
|
||||
const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set());
|
||||
const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set());
|
||||
|
||||
const toggleAcCategory = useCallback((cat: string) => {
|
||||
setHiddenAcCategories(prev => {
|
||||
@ -131,7 +147,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setHiddenAcCategories]);
|
||||
|
||||
const toggleShipCategory = useCallback((cat: string) => {
|
||||
setHiddenShipCategories(prev => {
|
||||
@ -139,27 +155,27 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setHiddenShipCategories]);
|
||||
|
||||
// Nationality filter state (Korea tab)
|
||||
const [hiddenNationalities, setHiddenNationalities] = useState<Set<string>>(new Set());
|
||||
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
|
||||
const toggleNationality = useCallback((nat: string) => {
|
||||
setHiddenNationalities(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setHiddenNationalities]);
|
||||
|
||||
// Fishing vessel nationality filter state
|
||||
const [hiddenFishingNats, setHiddenFishingNats] = useState<Set<string>>(new Set());
|
||||
const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set());
|
||||
const toggleFishingNat = useCallback((nat: string) => {
|
||||
setHiddenFishingNats(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setHiddenFishingNats]);
|
||||
|
||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||
|
||||
@ -214,16 +230,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
refreshKey,
|
||||
});
|
||||
|
||||
// Vessel analysis (Python prediction 결과)
|
||||
const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea');
|
||||
|
||||
// Korea filters hook
|
||||
const koreaFiltersResult = useKoreaFilters(
|
||||
koreaData.ships,
|
||||
koreaData.visibleShips,
|
||||
currentTime,
|
||||
vesselAnalysis.analysisMap,
|
||||
koreaLayers.cnFishing,
|
||||
);
|
||||
|
||||
const toggleLayer = useCallback((key: string) => {
|
||||
setLayers(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
|
||||
}, []);
|
||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}, [setLayers]);
|
||||
|
||||
// Handle event card click from timeline: fly to location on map
|
||||
const handleEventFlyTo = useCallback((event: GeoEvent) => {
|
||||
@ -281,6 +302,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
{dashboardTab === 'korea' && (
|
||||
<div className="mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)}
|
||||
title={t('filters.illegalFishing')}
|
||||
>
|
||||
<span className="text-[11px]">🚫🐟</span>
|
||||
{t('filters.illegalFishing')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
|
||||
@ -482,45 +512,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
||||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||
]}
|
||||
overseasItems={(() => {
|
||||
const fc = (ck: string, st?: string) => filterFacilities(ck, st as never).length;
|
||||
const energyChildren = (ck: string) => [
|
||||
{ key: `${ck}Power`, label: '발전소', color: '#a855f7', count: fc(ck, 'power') },
|
||||
{ key: `${ck}Wind`, label: '풍력단지', color: '#22d3ee', count: fc(ck, 'wind') },
|
||||
{ key: `${ck}Nuclear`, label: '원자력발전소', color: '#f59e0b', count: fc(ck, 'nuclear') },
|
||||
{ key: `${ck}Thermal`, label: '화력발전소', color: '#64748b', count: fc(ck, 'thermal') },
|
||||
];
|
||||
const hazardChildren = (ck: string) => [
|
||||
{ key: `${ck}Petrochem`, label: '석유화학단지', color: '#f97316', count: fc(ck, 'petrochem') },
|
||||
{ key: `${ck}Lng`, label: 'LNG저장기지', color: '#0ea5e9', count: fc(ck, 'lng') },
|
||||
{ key: `${ck}OilTank`, label: '유류저장탱크', color: '#eab308', count: fc(ck, 'oil_tank') },
|
||||
{ key: `${ck}HazPort`, label: '위험물항만하역시설', color: '#dc2626', count: fc(ck, 'haz_port') },
|
||||
];
|
||||
const fullCountry = (key: string, label: string, color: string, ck: string) => ({
|
||||
key, label, color, children: [
|
||||
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', children: energyChildren(ck) },
|
||||
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', children: hazardChildren(ck) },
|
||||
],
|
||||
});
|
||||
const compactCountry = (key: string, label: string, color: string, ck: string) => ({
|
||||
key, label, color, children: [
|
||||
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', count: filterFacilities(ck).filter(f => f.category === 'energy').length },
|
||||
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', count: filterFacilities(ck).filter(f => f.category === 'hazard').length },
|
||||
],
|
||||
});
|
||||
return [
|
||||
fullCountry('overseasUS', '🇺🇸 미국', '#3b82f6', 'us'),
|
||||
fullCountry('overseasIsrael', '🇮🇱 이스라엘', '#0ea5e9', 'il'),
|
||||
fullCountry('overseasIran', '🇮🇷 이란', '#22c55e', 'ir'),
|
||||
fullCountry('overseasUAE', '🇦🇪 UAE', '#f59e0b', 'ae'),
|
||||
fullCountry('overseasSaudi', '🇸🇦 사우디아라비아', '#84cc16', 'sa'),
|
||||
compactCountry('overseasOman', '🇴🇲 오만', '#e11d48', 'om'),
|
||||
compactCountry('overseasQatar', '🇶🇦 카타르', '#8b5cf6', 'qa'),
|
||||
compactCountry('overseasKuwait', '🇰🇼 쿠웨이트', '#f97316', 'kw'),
|
||||
compactCountry('overseasIraq', '🇮🇶 이라크', '#65a30d', 'iq'),
|
||||
compactCountry('overseasBahrain', '🇧🇭 바레인', '#e11d48', 'bh'),
|
||||
];
|
||||
})()}
|
||||
overseasItems={[
|
||||
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
|
||||
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
|
||||
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
|
||||
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
|
||||
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
|
||||
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
|
||||
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
|
||||
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
|
||||
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
|
||||
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onAcCategoryToggle={toggleAcCategory}
|
||||
@ -616,10 +619,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<main className="app-main">
|
||||
<div className="map-panel">
|
||||
{showFieldAnalysis && (
|
||||
<FieldAnalysisModal ships={koreaData.ships} onClose={() => setShowFieldAnalysis(false)} />
|
||||
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
|
||||
)}
|
||||
<KoreaMap
|
||||
ships={koreaFiltersResult.filteredShips}
|
||||
allShips={koreaData.visibleShips}
|
||||
aircraft={koreaData.visibleAircraft}
|
||||
satellites={koreaData.satPositions}
|
||||
layers={koreaLayers}
|
||||
@ -630,6 +634,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
@ -642,48 +647,49 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
satelliteCount={koreaData.satPositions.length}
|
||||
extraLayers={[
|
||||
// 해양안전
|
||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', group: '해양안전' },
|
||||
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15, group: '해양안전' },
|
||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 56, group: '해양안전' },
|
||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', group: '해양안전' },
|
||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', group: '해양안전' },
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' },
|
||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' },
|
||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
|
||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
|
||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
|
||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
|
||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
|
||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
|
||||
// 국가기관망
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59, group: '국가기관망' },
|
||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8, group: '에너지/발전시설' },
|
||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: 46, group: '국가기관망' },
|
||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38, group: '국가기관망' },
|
||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32, group: '국가기관망' },
|
||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '해양안전' },
|
||||
// 위험시설
|
||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: 5, group: '위험시설' },
|
||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: 10, group: '위험시설' },
|
||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: 15, group: '위험시설' },
|
||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: 6, group: '위험시설' },
|
||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
|
||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
|
||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
|
||||
// 에너지/발전시설
|
||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: 5, group: '에너지/발전시설' },
|
||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: 5, group: '에너지/발전시설' },
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
|
||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
|
||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
|
||||
// 위험시설
|
||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
|
||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
|
||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
|
||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
|
||||
// 산업공정/제조시설
|
||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: 6, group: '산업공정/제조시설' },
|
||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: 5, group: '산업공정/제조시설' },
|
||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: 5, group: '산업공정/제조시설' },
|
||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
|
||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
|
||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
|
||||
]}
|
||||
overseasItems={[
|
||||
{
|
||||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
|
||||
children: [
|
||||
{ key: 'cnPower', label: '발전소', color: '#a855f7' },
|
||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444' },
|
||||
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
|
||||
children: [
|
||||
{ key: 'jpPower', label: '발전소', color: '#a855f7' },
|
||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444' },
|
||||
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||
|
||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||
const AC_CAT_COLORS: Record<string, string> = {
|
||||
@ -128,12 +129,6 @@ interface OverseasItem {
|
||||
children?: OverseasItem[];
|
||||
}
|
||||
|
||||
/** Recursively count leaf nodes (items without children) */
|
||||
function countOverseasTree(item: OverseasItem): number {
|
||||
if (!item.children?.length) return item.count ?? 0;
|
||||
return item.children.reduce((sum, c) => sum + countOverseasTree(c), 0);
|
||||
}
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
@ -178,7 +173,7 @@ export function LayerPanel({
|
||||
onFishingNatToggle,
|
||||
}: LayerPanelProps) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(['ships']));
|
||||
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
|
||||
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((key: string) => {
|
||||
@ -187,7 +182,7 @@ export function LayerPanel({
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setExpanded]);
|
||||
|
||||
const toggleLegend = useCallback((key: string) => {
|
||||
setLegendOpen(prev => {
|
||||
@ -197,13 +192,10 @@ export function LayerPanel({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const militaryCount = Object.entries(aircraftByCategory)
|
||||
const _militaryCount = Object.entries(aircraftByCategory)
|
||||
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||
.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
||||
const overseasTotalCount = overseasItems
|
||||
? overseasItems.reduce((sum, item) => sum + countOverseasTree(item), 0)
|
||||
: 0;
|
||||
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
@ -549,19 +541,23 @@ export function LayerPanel({
|
||||
|
||||
<div className="layer-divider" />
|
||||
|
||||
{/* 해외시설 */}
|
||||
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
|
||||
<LayerTreeItem
|
||||
layerKey="militaryOnly"
|
||||
label={t('layers.militaryOnly')}
|
||||
count={overseasTotalCount || militaryCount}
|
||||
layerKey="overseas-section"
|
||||
label="해외시설"
|
||||
count={overseasItems?.reduce((sum, item) => {
|
||||
const parentOn = layers[item.key] ? 1 : 0;
|
||||
const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0;
|
||||
return sum + parentOn + childrenOn;
|
||||
}, 0) ?? 0}
|
||||
color="#f97316"
|
||||
active={layers.militaryOnly ?? false}
|
||||
active={expanded.has('overseas-section')}
|
||||
expandable
|
||||
isExpanded={expanded.has('militaryOnly')}
|
||||
onToggle={() => onToggle('militaryOnly')}
|
||||
onExpand={() => toggleExpand('militaryOnly')}
|
||||
isExpanded={expanded.has('overseas-section')}
|
||||
onToggle={() => toggleExpand('overseas-section')}
|
||||
onExpand={() => toggleExpand('overseas-section')}
|
||||
/>
|
||||
{expanded.has('militaryOnly') && overseasItems && overseasItems.length > 0 && (
|
||||
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
|
||||
<div className="layer-tree-children">
|
||||
{overseasItems.map(item => (
|
||||
<div key={item.key}>
|
||||
@ -569,7 +565,6 @@ export function LayerPanel({
|
||||
layerKey={item.key}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
count={item.count ?? countOverseasTree(item)}
|
||||
active={layers[item.key] ?? false}
|
||||
expandable={!!item.children?.length}
|
||||
isExpanded={expanded.has(`overseas-${item.key}`)}
|
||||
@ -579,34 +574,14 @@ export function LayerPanel({
|
||||
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
|
||||
<div className="layer-tree-children">
|
||||
{item.children.map(child => (
|
||||
<div key={child.key}>
|
||||
<LayerTreeItem
|
||||
layerKey={child.key}
|
||||
label={child.label}
|
||||
color={child.color}
|
||||
count={child.count ?? countOverseasTree(child)}
|
||||
active={layers[child.key] ?? false}
|
||||
expandable={!!child.children?.length}
|
||||
isExpanded={expanded.has(`overseas-${child.key}`)}
|
||||
onToggle={() => onToggle(child.key)}
|
||||
onExpand={child.children?.length ? () => toggleExpand(`overseas-${child.key}`) : undefined}
|
||||
/>
|
||||
{child.children?.length && expanded.has(`overseas-${child.key}`) && (
|
||||
<div className="layer-tree-children">
|
||||
{child.children.map(gc => (
|
||||
<LayerTreeItem
|
||||
key={gc.key}
|
||||
layerKey={gc.key}
|
||||
label={gc.label}
|
||||
color={gc.color}
|
||||
count={gc.count}
|
||||
active={layers[gc.key] ?? false}
|
||||
onToggle={() => onToggle(gc.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LayerTreeItem
|
||||
key={child.key}
|
||||
layerKey={child.key}
|
||||
label={child.label}
|
||||
color={child.color}
|
||||
active={layers[child.key] ?? false}
|
||||
onToggle={() => onToggle(child.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
53
frontend/src/components/korea/AnalysisOverlay.tsx
Normal file
53
frontend/src/components/korea/AnalysisOverlay.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Marker } from 'react-map-gl/maplibre';
|
||||
import type { VesselAnalysisDto, Ship } from '../../types';
|
||||
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
clusters: Map<number, string[]>;
|
||||
activeFilter: string | null;
|
||||
}
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
dto: VesselAnalysisDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위험도/다크베셀/스푸핑 마커는 useAnalysisDeckLayers + DeckGLOverlay로 GPU 렌더링.
|
||||
* 이 컴포넌트는 DOM Marker가 필요한 leader 별 아이콘만 담당.
|
||||
*/
|
||||
export function AnalysisOverlay({ ships, analysisMap, clusters: _clusters, activeFilter }: Props) {
|
||||
const analyzedShips: AnalyzedShip[] = useMemo(() => {
|
||||
return ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
|
||||
}, [ships, analysisMap]);
|
||||
|
||||
// 선단 leader 별 아이콘 (cnFishing 필터 ON)
|
||||
const leaderShips = useMemo(() => {
|
||||
if (activeFilter !== 'cnFishing') return [];
|
||||
return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader);
|
||||
}, [analyzedShips, activeFilter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 선단 leader 별 아이콘 */}
|
||||
{leaderShips.map(({ ship }) => (
|
||||
<Marker key={`leader-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{
|
||||
marginBottom: 6,
|
||||
fontSize: 10,
|
||||
color: '#f59e0b',
|
||||
textShadow: '0 0 3px #000',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
★
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
439
frontend/src/components/korea/AnalysisStatsPanel.tsx
Normal file
439
frontend/src/components/korea/AnalysisStatsPanel.tsx
Normal file
@ -0,0 +1,439 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
||||
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { fetchVesselTrack } from '../../services/vesselTrack';
|
||||
|
||||
interface Props {
|
||||
stats: AnalysisStats;
|
||||
lastUpdated: number;
|
||||
isLoading: boolean;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
ships: Ship[];
|
||||
allShips?: Ship[];
|
||||
onShipSelect?: (mmsi: string) => void;
|
||||
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
interface VesselListItem {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
score: number;
|
||||
dto: VesselAnalysisDto;
|
||||
}
|
||||
|
||||
/** unix ms → HH:MM 형식 */
|
||||
function formatTime(ms: number): string {
|
||||
if (ms === 0) return '--:--';
|
||||
const d = new Date(ms);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
const RISK_COLOR: Record<RiskLevel, string> = {
|
||||
CRITICAL: '#ef4444',
|
||||
HIGH: '#f97316',
|
||||
MEDIUM: '#eab308',
|
||||
LOW: '#22c55e',
|
||||
};
|
||||
|
||||
const RISK_EMOJI: Record<RiskLevel, string> = {
|
||||
CRITICAL: '🔴',
|
||||
HIGH: '🟠',
|
||||
MEDIUM: '🟡',
|
||||
LOW: '🟢',
|
||||
};
|
||||
|
||||
const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||
|
||||
const LEGEND_LINES = [
|
||||
'위험도 점수 기준 (0~100)',
|
||||
'',
|
||||
'■ 위치 (최대 40점)',
|
||||
' 영해 내: 40 / 접속수역: 10',
|
||||
'',
|
||||
'■ 조업 행위 (최대 30점)',
|
||||
' 영해 내 조업: 20 / 기타 조업: 5',
|
||||
' U-turn 패턴: 10',
|
||||
'',
|
||||
'■ AIS 조작 (최대 35점)',
|
||||
' 순간이동: 20 / 장시간 갭: 15',
|
||||
' 단시간 갭: 5',
|
||||
'',
|
||||
'■ 허가 이력 (최대 20점)',
|
||||
' 미허가 어선: 20',
|
||||
'',
|
||||
'CRITICAL ≥70 / HIGH ≥50',
|
||||
'MEDIUM ≥30 / LOW <30',
|
||||
'',
|
||||
'UCAF: 어구별 조업속도 매칭 비율',
|
||||
'UCFT: 조업-항행 구분 신뢰도',
|
||||
'스푸핑: 순간이동+SOG급변+BD09 종합',
|
||||
];
|
||||
|
||||
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
|
||||
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
|
||||
const toggleExpanded = () => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
onExpandedChange?.(next);
|
||||
};
|
||||
// 마운트 시 저장된 상태를 부모에 동기화
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { onExpandedChange?.(expanded); }, []);
|
||||
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
|
||||
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
|
||||
|
||||
const gearStats = useMemo(() => {
|
||||
const source = allShips ?? ships;
|
||||
const gearPattern = /^(.+?)_\d+_\d+_?$/;
|
||||
const STALE_MS = 60 * 60_000; // 60분 이내만
|
||||
const now = Date.now();
|
||||
const parentMap = new Map<string, number>();
|
||||
for (const s of source) {
|
||||
if (now - s.lastSeen > STALE_MS) continue;
|
||||
const m = (s.name || '').match(gearPattern);
|
||||
if (m) {
|
||||
const parent = m[1].trim();
|
||||
parentMap.set(parent, (parentMap.get(parent) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
groups: parentMap.size,
|
||||
count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0),
|
||||
};
|
||||
}, [allShips, ships]);
|
||||
|
||||
const vesselList = useMemo((): VesselListItem[] => {
|
||||
if (!selectedLevel) return [];
|
||||
const list: VesselListItem[] = [];
|
||||
for (const [mmsi, dto] of analysisMap) {
|
||||
if (dto.algorithms.riskScore.level !== selectedLevel) continue;
|
||||
const ship = ships.find(s => s.mmsi === mmsi);
|
||||
list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto });
|
||||
}
|
||||
return list.sort((a, b) => b.score - a.score).slice(0, 50);
|
||||
}, [selectedLevel, analysisMap, ships]);
|
||||
|
||||
const handleLevelClick = (level: RiskLevel) => {
|
||||
setSelectedLevel(prev => (prev === level ? null : level));
|
||||
setSelectedMmsi(null);
|
||||
};
|
||||
|
||||
const handleVesselClick = async (mmsi: string) => {
|
||||
setSelectedMmsi(prev => (prev === mmsi ? null : mmsi));
|
||||
onShipSelect?.(mmsi);
|
||||
const coords = await fetchVesselTrack(mmsi);
|
||||
if (coords.length > 0) onTrackLoad?.(mmsi, coords);
|
||||
};
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 50,
|
||||
zIndex: 10,
|
||||
minWidth: 200,
|
||||
maxWidth: 280,
|
||||
backgroundColor: 'rgba(12, 24, 37, 0.92)',
|
||||
border: '1px solid rgba(99, 179, 237, 0.25)',
|
||||
borderRadius: 8,
|
||||
color: '#e2e8f0',
|
||||
fontFamily: 'monospace, sans-serif',
|
||||
fontSize: 11,
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 10px',
|
||||
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const toggleButtonStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
const bodyStyle: React.CSSProperties = {
|
||||
padding: '8px 10px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 3,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: '#94a3b8',
|
||||
};
|
||||
|
||||
const valueStyle: React.CSSProperties = {
|
||||
fontWeight: 700,
|
||||
color: '#e2e8f0',
|
||||
};
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
borderTop: '1px solid rgba(99, 179, 237, 0.15)',
|
||||
margin: '6px 0',
|
||||
};
|
||||
|
||||
const riskRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const legendDividerStyle: React.CSSProperties = {
|
||||
...dividerStyle,
|
||||
marginTop: 8,
|
||||
};
|
||||
|
||||
const legendBodyStyle: React.CSSProperties = {
|
||||
fontSize: 9,
|
||||
color: '#475569',
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
{/* 헤더 */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>AI 분석</span>
|
||||
{isLoading && (
|
||||
<span style={{ fontSize: 9, color: '#fbbf24' }}>로딩중...</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 9, color: '#64748b' }}>{formatTime(lastUpdated)}</span>
|
||||
<button
|
||||
style={toggleButtonStyle}
|
||||
onClick={() => setShowLegend(prev => !prev)}
|
||||
aria-label="범례 보기"
|
||||
title="범례"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button
|
||||
style={toggleButtonStyle}
|
||||
onClick={toggleExpanded}
|
||||
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
|
||||
>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
{expanded && (
|
||||
<div style={bodyStyle}>
|
||||
{isEmpty ? (
|
||||
<div style={{ color: '#64748b', textAlign: 'center', padding: '6px 0' }}>
|
||||
분석 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 요약 행 */}
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>전체</span>
|
||||
<span style={valueStyle}>{stats.total}</span>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>다크베셀</span>
|
||||
<span style={{ ...valueStyle, color: '#a855f7' }}>{stats.dark}</span>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>GPS스푸핑</span>
|
||||
<span style={{ ...valueStyle, color: '#ef4444' }}>{stats.spoofing}</span>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>선단수</span>
|
||||
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
|
||||
</div>
|
||||
{gearStats.groups > 0 && (
|
||||
<>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>어구그룹</span>
|
||||
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<span style={labelStyle}>어구수</span>
|
||||
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={dividerStyle} />
|
||||
|
||||
{/* 위험도 카운트 행 — 클릭 가능 */}
|
||||
<div style={riskRowStyle}>
|
||||
{RISK_LEVELS.map(level => {
|
||||
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
||||
const isActive = selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => handleLevelClick(level)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
||||
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
||||
borderRadius: 4,
|
||||
color: '#cbd5e1',
|
||||
fontSize: 10,
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
fontFamily: 'monospace, sans-serif',
|
||||
}}
|
||||
>
|
||||
<span>{RISK_EMOJI[level]}</span>
|
||||
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선박 목록 */}
|
||||
{selectedLevel !== null && vesselList.length > 0 && (
|
||||
<>
|
||||
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
||||
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||
{vesselList.map(item => {
|
||||
const isExpanded = selectedMmsi === item.mmsi;
|
||||
const color = RISK_COLOR[selectedLevel];
|
||||
const { dto } = item;
|
||||
return (
|
||||
<div key={item.mmsi}>
|
||||
{/* 선박 행 */}
|
||||
<div
|
||||
onClick={() => { void handleVesselClick(item.mmsi); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 4px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
borderLeft: isExpanded ? `2px solid ${color}` : '2px solid transparent',
|
||||
backgroundColor: isExpanded ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9, flexShrink: 0 }}>
|
||||
{item.mmsi}
|
||||
</span>
|
||||
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
|
||||
{item.score}점
|
||||
</span>
|
||||
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}>▶</span>
|
||||
</div>
|
||||
|
||||
{/* 근거 상세 */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
paddingLeft: 12,
|
||||
paddingBottom: 4,
|
||||
fontSize: 9,
|
||||
color: '#64748b',
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
<div>
|
||||
위치: {dto.algorithms.location.zone}
|
||||
{' '}(기선 {dto.algorithms.location.distToBaselineNm.toFixed(1)}NM)
|
||||
</div>
|
||||
<div>
|
||||
활동: {dto.algorithms.activity.state}
|
||||
{' '}(UCAF {dto.algorithms.activity.ucafScore.toFixed(2)})
|
||||
</div>
|
||||
{dto.algorithms.darkVessel.isDark && (
|
||||
<div>다크: {dto.algorithms.darkVessel.gapDurationMin}분 갭</div>
|
||||
)}
|
||||
{dto.algorithms.gpsSpoofing.spoofingScore > 0 && (
|
||||
<div>
|
||||
GPS: 스푸핑 {Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%
|
||||
</div>
|
||||
)}
|
||||
{dto.algorithms.cluster.clusterSize > 1 && (
|
||||
<div>
|
||||
선단: {dto.algorithms.fleetRole.role}
|
||||
{' '}({dto.algorithms.cluster.clusterSize}척)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedLevel !== null && vesselList.length === 0 && (
|
||||
<>
|
||||
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
||||
<div style={{ fontSize: 9, color: '#64748b', textAlign: 'center', padding: '4px 0' }}>
|
||||
해당 레벨 선박 없음
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 범례 */}
|
||||
{showLegend && (
|
||||
<>
|
||||
<div style={legendDividerStyle} />
|
||||
<div style={legendBodyStyle}>
|
||||
{LEGEND_LINES.map((line, i) => (
|
||||
<div key={i} style={{ color: line.startsWith('■') ? '#64748b' : '#475569' }}>
|
||||
{line || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,57 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { Ship } from '../../types';
|
||||
import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { FishingNetIcon, TrawlNetIcon, GillnetIcon, StowNetIcon, PurseSeineIcon } from '../icons/FishingNetIcon';
|
||||
|
||||
/** 어구 아이콘 컴포넌트 매핑 */
|
||||
function GearIcon({ gear, size = 14 }: { gear: FishingGearType; size?: number }) {
|
||||
const meta = GEAR_LABELS[gear];
|
||||
const color = meta?.color || '#888';
|
||||
switch (gear) {
|
||||
case 'trawl_pair':
|
||||
case 'trawl_single':
|
||||
return <TrawlNetIcon color={color} size={size} />;
|
||||
case 'gillnet':
|
||||
return <GillnetIcon color={color} size={size} />;
|
||||
case 'stow_net':
|
||||
return <StowNetIcon color={color} size={size} />;
|
||||
case 'purse_seine':
|
||||
return <PurseSeineIcon color={color} size={size} />;
|
||||
default:
|
||||
return <FishingNetIcon color={color} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** 선박 역할 추정 — 속도/크기/카테고리 기반 */
|
||||
function estimateRole(ship: Ship): { role: string; roleKo: string; color: string } {
|
||||
const mtCat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const speed = ship.speed;
|
||||
const len = ship.length || 0;
|
||||
|
||||
// 운반선: 화물선/대형/미분류 + 저속
|
||||
if (mtCat === 'cargo' || (mtCat === 'unspecified' && len > 50)) {
|
||||
return { role: 'FC', roleKo: '운반', color: '#f97316' };
|
||||
}
|
||||
|
||||
// 어선 분류
|
||||
if (mtCat === 'fishing' || ship.category === 'fishing') {
|
||||
// 대형(>200톤급, 길이 40m+) → 본선
|
||||
if (len >= 40) {
|
||||
return { role: 'PT', roleKo: '본선', color: '#ef4444' };
|
||||
}
|
||||
// 소형(<30m) + 트롤 속도 → 부속선
|
||||
if (len > 0 && len < 30 && speed >= 2 && speed <= 5) {
|
||||
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
|
||||
}
|
||||
// 기본 어선
|
||||
return { role: 'FV', roleKo: '어선', color: '#22c55e' };
|
||||
}
|
||||
|
||||
return { role: '', roleKo: '', color: '#6b7280' };
|
||||
}
|
||||
import { Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
|
||||
/**
|
||||
* 어구/어망 이름에서 모선명 추출
|
||||
@ -79,68 +28,45 @@ interface GearToParentLink {
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
}
|
||||
|
||||
export function ChineseFishingOverlay({ ships }: Props) {
|
||||
// 중국 어선만 필터링
|
||||
const chineseFishing = useMemo(() => {
|
||||
return ships.filter(s => {
|
||||
if (s.flag !== 'CN') return false;
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
return cat === 'fishing' || s.category === 'fishing';
|
||||
});
|
||||
}, [ships]);
|
||||
|
||||
// 조업 분석 결과
|
||||
const analyzed = useMemo(() => {
|
||||
return chineseFishing.map(s => ({
|
||||
ship: s,
|
||||
analysis: analyzeFishing(s),
|
||||
role: estimateRole(s),
|
||||
}));
|
||||
}, [chineseFishing]);
|
||||
|
||||
// 조업 중인 선박만 (어구 아이콘 표시용)
|
||||
const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating), [analyzed]);
|
||||
|
||||
// 어구/어망 → 모선 연결 탐지
|
||||
export function ChineseFishingOverlay({ ships, analysisMap: _analysisMap }: Props) {
|
||||
// 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선)
|
||||
const gearLinks: GearToParentLink[] = useMemo(() => {
|
||||
// 어구/어망 선박 (이름_숫자_ 또는 이름% 패턴)
|
||||
const gearPattern = /^.+_\d+_\d*$|%$/;
|
||||
const gearShips = ships.filter(s => gearPattern.test(s.name));
|
||||
|
||||
if (gearShips.length === 0) return [];
|
||||
|
||||
// 모선 후보 (모든 선박의 이름 → Ship 매핑)
|
||||
// 모선 후보
|
||||
const nameMap = new Map<string, Ship>();
|
||||
for (const s of ships) {
|
||||
if (!gearPattern.test(s.name) && s.name) {
|
||||
// 정확한 이름 매핑
|
||||
nameMap.set(s.name.trim(), s);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DIST_DEG = 0.15; // ~10NM — 이 이상 떨어지면 연결 안 함
|
||||
const MAX_LINKS = 200; // 브라우저 성능 보호
|
||||
|
||||
const links: GearToParentLink[] = [];
|
||||
for (const gear of gearShips) {
|
||||
if (links.length >= MAX_LINKS) break;
|
||||
|
||||
const parentName = extractParentName(gear.name);
|
||||
if (!parentName) continue;
|
||||
if (!parentName || parentName.length < 3) continue; // 너무 짧은 이름 제외
|
||||
|
||||
// 정확히 일치하는 모선 찾기
|
||||
let parent = nameMap.get(parentName);
|
||||
// 정확 매칭만 (부분 매칭 제거 — 오탐 원인)
|
||||
const parent = nameMap.get(parentName);
|
||||
if (!parent) continue;
|
||||
|
||||
// 정확 매칭 없으면 부분 매칭 (앞부분이 같은 선박)
|
||||
if (!parent) {
|
||||
for (const [name, ship] of nameMap) {
|
||||
if (name.startsWith(parentName) || parentName.startsWith(name)) {
|
||||
parent = ship;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 거리 제한: ~10NM 이내만 연결
|
||||
const dlat = Math.abs(gear.lat - parent.lat);
|
||||
const dlng = Math.abs(gear.lng - parent.lng);
|
||||
if (dlat > MAX_DIST_DEG || dlng > MAX_DIST_DEG) continue;
|
||||
|
||||
if (parent) {
|
||||
links.push({ gear, parent, parentName });
|
||||
}
|
||||
links.push({ gear, parent, parentName });
|
||||
}
|
||||
return links;
|
||||
}, [ships]);
|
||||
@ -161,21 +87,6 @@ export function ChineseFishingOverlay({ ships }: Props) {
|
||||
})),
|
||||
}), [gearLinks]);
|
||||
|
||||
// 운반선 추정 (중국 화물선 중 어선 근처)
|
||||
const carriers = useMemo(() => {
|
||||
return ships.filter(s => {
|
||||
if (s.flag !== 'CN') return false;
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (cat !== 'cargo' && cat !== 'unspecified') return false;
|
||||
// 어선 5NM 이내에 있는 화물선
|
||||
return chineseFishing.some(f => {
|
||||
const dlat = Math.abs(s.lat - f.lat);
|
||||
const dlng = Math.abs(s.lng - f.lng);
|
||||
return dlat < 0.08 && dlng < 0.08; // ~5NM 근사
|
||||
});
|
||||
}).slice(0, 50); // 최대 50척
|
||||
}, [ships, chineseFishing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 어구/어망 → 모선 연결선 */}
|
||||
@ -193,74 +104,6 @@ export function ChineseFishingOverlay({ ships }: Props) {
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 어구/어망 위치 마커 (모선 연결된 것) — 최대 50개 */}
|
||||
{gearLinks.slice(0, 50).map(link => (
|
||||
<Marker key={`gearlink-${link.gear.mmsi}`} longitude={link.gear.lng} latitude={link.gear.lat} anchor="center">
|
||||
<div className="cn-fishing-no-events">
|
||||
<div style={{ filter: 'drop-shadow(0 0 3px #f9731688)' }}>
|
||||
<FishingNetIcon color="#f97316" size={10} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: '#f97316', textAlign: 'center',
|
||||
textShadow: '0 0 2px #000', fontWeight: 700, marginTop: -1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
← {link.parentName}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 조업 중 어선 — 어구 아이콘 — 최대 80개 */}
|
||||
{operating.slice(0, 80).map(({ ship, analysis }) => {
|
||||
const meta = GEAR_LABELS[analysis.gearType];
|
||||
return (
|
||||
<Marker key={`gear-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div className="cn-fishing-no-events" style={{
|
||||
marginBottom: 8,
|
||||
filter: `drop-shadow(0 0 3px ${meta?.color || '#f97316'}88)`,
|
||||
opacity: 0.85,
|
||||
}}>
|
||||
<GearIcon gear={analysis.gearType} size={12} />
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 본선/부속선/어선 역할 라벨 — 최대 100개 */}
|
||||
{analyzed.filter(a => a.role.role).slice(0, 100).map(({ ship, role }) => (
|
||||
<Marker key={`role-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="top">
|
||||
<div className="cn-fishing-no-events" style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: role.color,
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{role.roleKo}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 운반선 라벨 */}
|
||||
{carriers.map(s => (
|
||||
<Marker key={`carrier-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="top">
|
||||
<div className="cn-fishing-no-events" style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: '#f97316',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
운반
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||
import { CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||
import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard';
|
||||
|
||||
const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
@ -13,143 +12,52 @@ const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
navy: '#3b82f6',
|
||||
};
|
||||
|
||||
const TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
hq: 24,
|
||||
regional: 20,
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
navy: 18,
|
||||
};
|
||||
|
||||
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
|
||||
function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) {
|
||||
const color = TYPE_COLOR[type];
|
||||
const isVts = type === 'vts';
|
||||
|
||||
if (type === 'navy') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="4" x2="12" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="4" r="2" fill={color} />
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke={color} strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVts) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
|
||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
|
||||
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
|
||||
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
|
||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
||||
{(type === 'hq' || type === 'regional') && (
|
||||
<circle cx="12" cy="9" r="1" fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: CoastGuardFacility | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CoastGuardLayer() {
|
||||
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
||||
export function CoastGuardLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{COAST_GUARD_FACILITIES.map(f => {
|
||||
const size = TYPE_SIZE[f.type];
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<CoastGuardIcon type={f.type} size={size} />
|
||||
{(f.type === 'hq' || f.type === 'regional') && (
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold text-white">
|
||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'navy' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #3b82f6, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#3b82f6]">
|
||||
{f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'vts' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
|
||||
VTS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
gap: 6, padding: '6px 10px',
|
||||
}}>
|
||||
{selected.type === 'navy' ? (
|
||||
<span style={{ fontSize: 16 }}>⚓</span>
|
||||
) : selected.type === 'vts' ? (
|
||||
<span style={{ fontSize: 16 }}>📡</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 16 }}>🚔</span>
|
||||
)}
|
||||
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#4dabf7',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
gap: 6, padding: '6px 10px',
|
||||
}}>
|
||||
{selected.type === 'navy' ? (
|
||||
<span style={{ fontSize: 16 }}>⚓</span>
|
||||
) : selected.type === 'vts' ? (
|
||||
<span style={{ fontSize: 16 }}>📡</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 16 }}>🚔</span>
|
||||
)}
|
||||
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#4dabf7',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Ship, ChnPrmShipInfo } from '../../types';
|
||||
import { analyzeFishing } from '../../utils/fishingAnalysis';
|
||||
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
|
||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
||||
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||
|
||||
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
||||
const mtPhotoCache = new Map<string, string | null>();
|
||||
@ -55,16 +56,8 @@ const C = {
|
||||
border2: '#0E2035',
|
||||
} as const;
|
||||
|
||||
// 황해 위치 기반 수역 분류 (근사값)
|
||||
function classifyZone(lng: number): string {
|
||||
if (lng > 124.8) return 'TERRITORIAL';
|
||||
if (lng > 124.2) return 'CONTIGUOUS';
|
||||
if (lng > 121.5) return 'EEZ';
|
||||
return 'BEYOND';
|
||||
}
|
||||
|
||||
// AIS 수신 기준 선박 상태 분류
|
||||
function classifyState(ship: Ship): string {
|
||||
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
|
||||
function classifyStateFallback(ship: Ship): string {
|
||||
const ageMins = (Date.now() - ship.lastSeen) / 60000;
|
||||
if (ageMins > 20) return 'AIS_LOSS';
|
||||
if (ship.speed <= 0.5) return 'STATIONARY';
|
||||
@ -72,11 +65,11 @@ function classifyState(ship: Ship): string {
|
||||
return 'FISHING';
|
||||
}
|
||||
|
||||
function getAlertLevel(zone: string, state: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
|
||||
if (zone === 'TERRITORIAL') return 'CRITICAL';
|
||||
if (state === 'AIS_LOSS') return 'WATCH';
|
||||
if (zone === 'CONTIGUOUS' && state === 'FISHING') return 'WATCH';
|
||||
if (zone === 'EEZ' && state === 'FISHING') return 'MONITOR';
|
||||
// Python RiskLevel → 경보 등급 매핑
|
||||
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
|
||||
if (level === 'CRITICAL') return 'CRITICAL';
|
||||
if (level === 'HIGH') return 'WATCH';
|
||||
if (level === 'MEDIUM') return 'MONITOR';
|
||||
return 'NORMAL';
|
||||
}
|
||||
|
||||
@ -94,29 +87,6 @@ function zoneLabel(z: string): string {
|
||||
return map[z] ?? z;
|
||||
}
|
||||
|
||||
// 근접 클러스터링 (~5NM 내 2척 이상 집단)
|
||||
function buildClusters(vessels: ProcessedVessel[]): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
let clusterIdx = 0;
|
||||
for (let i = 0; i < vessels.length; i++) {
|
||||
if (result.has(vessels[i].ship.mmsi)) continue;
|
||||
const cluster: string[] = [vessels[i].ship.mmsi];
|
||||
for (let j = i + 1; j < vessels.length; j++) {
|
||||
if (result.has(vessels[j].ship.mmsi)) continue;
|
||||
const dlat = Math.abs(vessels[i].ship.lat - vessels[j].ship.lat);
|
||||
const dlng = Math.abs(vessels[i].ship.lng - vessels[j].ship.lng);
|
||||
if (dlat < 0.08 && dlng < 0.08) {
|
||||
cluster.push(vessels[j].ship.mmsi);
|
||||
}
|
||||
}
|
||||
if (cluster.length >= 2) {
|
||||
clusterIdx++;
|
||||
const id = `C-${String(clusterIdx).padStart(2, '0')}`;
|
||||
cluster.forEach(mmsi => result.set(mmsi, id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ProcessedVessel {
|
||||
ship: Ship;
|
||||
@ -137,6 +107,7 @@ interface LogEntry {
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@ -152,7 +123,9 @@ const PIPE_STEPS = [
|
||||
|
||||
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
|
||||
|
||||
export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
|
||||
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
|
||||
const [activeFilter, setActiveFilter] = useState('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
@ -167,25 +140,36 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
return cat === 'fishing' || s.category === 'fishing';
|
||||
}), [ships]);
|
||||
|
||||
// 선박 데이터 처리
|
||||
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
|
||||
const processed = useMemo((): ProcessedVessel[] => {
|
||||
const baseList = cnFishing.map(ship => {
|
||||
const zone = classifyZone(ship.lng);
|
||||
const state = classifyState(ship);
|
||||
const alert = getAlertLevel(zone, state);
|
||||
const analysis = analyzeFishing(ship);
|
||||
const gear = analysis.gearType;
|
||||
const vtype =
|
||||
(gear === 'trawl_pair' || gear === 'trawl_single') ? 'TRAWL' :
|
||||
gear === 'purse_seine' ? 'PURSE' :
|
||||
gear === 'gillnet' ? 'GILLNET' :
|
||||
gear === 'stow_net' ? 'TRAP' :
|
||||
'TRAWL';
|
||||
return { ship, zone, state, alert, vtype, cluster: '' };
|
||||
return cnFishing.map(ship => {
|
||||
const dto = analysisMap.get(ship.mmsi);
|
||||
|
||||
// 수역: Python → GeoJSON 폴리곤 fallback
|
||||
let zone: string;
|
||||
if (dto) {
|
||||
zone = dto.algorithms.location.zone;
|
||||
} else {
|
||||
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
|
||||
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
|
||||
}
|
||||
|
||||
// 행동 상태: Python → AIS fallback
|
||||
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
|
||||
|
||||
// 경보 등급: Python 위험도 직접 사용
|
||||
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
|
||||
|
||||
// 어구 분류: Python classification
|
||||
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
|
||||
|
||||
// 클러스터: Python cluster ID
|
||||
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
|
||||
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
|
||||
|
||||
return { ship, zone, state, alert, vtype, cluster };
|
||||
});
|
||||
const clusterMap = buildClusters(baseList);
|
||||
return baseList.map(v => ({ ...v, cluster: clusterMap.get(v.ship.mmsi) ?? '—' }));
|
||||
}, [cnFishing]);
|
||||
}, [cnFishing, analysisMap]);
|
||||
|
||||
// 필터 + 정렬
|
||||
const displayed = useMemo(() => {
|
||||
@ -201,24 +185,31 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
.sort((a, b) => ALERT_ORDER[a.alert] - ALERT_ORDER[b.alert]);
|
||||
}, [processed, activeFilter, search]);
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => ({
|
||||
total: processed.length,
|
||||
territorial: processed.filter(v => v.zone === 'TERRITORIAL').length,
|
||||
fishing: processed.filter(v => v.state === 'FISHING').length,
|
||||
aisLoss: processed.filter(v => v.state === 'AIS_LOSS').length,
|
||||
gpsAnomaly: 0,
|
||||
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
|
||||
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
|
||||
purse: processed.filter(v => v.vtype === 'PURSE').length,
|
||||
}), [processed]);
|
||||
// 통계 — Python 분석 결과 기반
|
||||
const stats = useMemo(() => {
|
||||
let gpsAnomaly = 0;
|
||||
for (const v of processed) {
|
||||
const dto = analysisMap.get(v.ship.mmsi);
|
||||
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||
}
|
||||
return {
|
||||
total: processed.length,
|
||||
territorial: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
|
||||
fishing: processed.filter(v => v.state === 'FISHING').length,
|
||||
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
|
||||
gpsAnomaly,
|
||||
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
|
||||
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
|
||||
purse: processed.filter(v => v.vtype === 'PURSE').length,
|
||||
};
|
||||
}, [processed, analysisMap]);
|
||||
|
||||
// 구역별 카운트
|
||||
// 구역별 카운트 — Python zone 분류 기반
|
||||
const zoneCounts = useMemo(() => ({
|
||||
terr: processed.filter(v => v.zone === 'TERRITORIAL').length,
|
||||
cont: processed.filter(v => v.zone === 'CONTIGUOUS').length,
|
||||
eez: processed.filter(v => v.zone === 'EEZ').length,
|
||||
beyond: processed.filter(v => v.zone === 'BEYOND').length,
|
||||
terr: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
|
||||
cont: processed.filter(v => v.zone === 'CONTIGUOUS_ZONE' || v.zone === 'ZONE_II').length,
|
||||
eez: processed.filter(v => v.zone === 'EEZ_OR_BEYOND' || v.zone === 'ZONE_III' || v.zone === 'ZONE_IV').length,
|
||||
beyond: processed.filter(v => !['TERRITORIAL_SEA', 'CONTIGUOUS_ZONE', 'EEZ_OR_BEYOND', 'ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'].includes(v.zone)).length,
|
||||
}), [processed]);
|
||||
|
||||
// 초기 경보 로그 생성
|
||||
@ -382,10 +373,10 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
{ label: '영해 침범', val: stats.territorial, color: C.red, sub: '12NM 이내' },
|
||||
{ label: '조업 중', val: stats.fishing, color: C.amber, sub: 'SOG 0.5–5.0kt' },
|
||||
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
|
||||
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' },
|
||||
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' },
|
||||
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: '규칙 기반 분류' },
|
||||
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: '규칙 기반 분류' },
|
||||
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 스푸핑 50%↑' },
|
||||
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'DBSCAN 군집' },
|
||||
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'Python 분류' },
|
||||
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: 'Python 분류' },
|
||||
].map(({ label, val, color, sub }) => (
|
||||
<div key={label} style={{
|
||||
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
|
||||
@ -485,8 +476,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
{ label: '조업 패턴', val: 'UCAF/UCFT SOG', color: C.ink2 },
|
||||
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
|
||||
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
|
||||
{ label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 },
|
||||
{ label: '선종 분류', val: '규칙 기반 (Python 연동 예정)', color: C.ink2 },
|
||||
{ label: '클러스터', val: 'DBSCAN 3NM (Python)', color: C.ink2 },
|
||||
{ label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green },
|
||||
].map(({ label, val, color }) => (
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||
@ -680,7 +671,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
</span>
|
||||
))}
|
||||
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
|
||||
AIS 4분 갱신 | Shepperson(2017) 기준 | 규칙 기반 분류 | 근접 클러스터 5NM
|
||||
AIS 4분 갱신 | Python 7단계 파이프라인 | DBSCAN 3NM 클러스터 | GeoJSON 수역 분류
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -709,10 +700,20 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
||||
{ label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink },
|
||||
{ label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
|
||||
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) },
|
||||
{ label: '추정 선종', val: selectedVessel.vtype, color: C.ink },
|
||||
{ label: '선종 (Python)', val: selectedVessel.vtype, color: C.ink },
|
||||
{ label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
|
||||
{ label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
|
||||
{ label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
|
||||
{ label: '클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
|
||||
{ label: '위험도', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
|
||||
...(() => {
|
||||
const dto = analysisMap.get(selectedVessel.ship.mmsi);
|
||||
if (!dto) return [{ label: 'AI 분석', val: '미분석', color: C.ink3 }];
|
||||
return [
|
||||
{ label: '위험 점수', val: `${dto.algorithms.riskScore.score}점`, color: alertColor(selectedVessel.alert) },
|
||||
{ label: 'GPS 스푸핑', val: `${Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, color: dto.algorithms.gpsSpoofing.spoofingScore > 0.5 ? C.red : C.green },
|
||||
{ label: 'AIS 공백', val: dto.algorithms.darkVessel.isDark ? `${Math.round(dto.algorithms.darkVessel.gapDurationMin)}분` : '정상', color: dto.algorithms.darkVessel.isDark ? C.red : C.green },
|
||||
{ label: '선단 역할', val: dto.algorithms.fleetRole.role, color: dto.algorithms.fleetRole.isLeader ? C.amber : C.ink2 },
|
||||
];
|
||||
})(),
|
||||
].map(({ label, val, color }) => (
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: `1px solid ${C.border2}` }}>
|
||||
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||
|
||||
57
frontend/src/components/korea/FishingZoneLayer.tsx
Normal file
57
frontend/src/components/korea/FishingZoneLayer.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Source, Layer } from 'react-map-gl/maplibre';
|
||||
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
|
||||
|
||||
const ZONE_FILL: Record<string, string> = {
|
||||
ZONE_I: 'rgba(59, 130, 246, 0.15)',
|
||||
ZONE_II: 'rgba(16, 185, 129, 0.15)',
|
||||
ZONE_III: 'rgba(245, 158, 11, 0.15)',
|
||||
ZONE_IV: 'rgba(239, 68, 68, 0.15)',
|
||||
};
|
||||
|
||||
const ZONE_LINE: Record<string, string> = {
|
||||
ZONE_I: 'rgba(59, 130, 246, 0.6)',
|
||||
ZONE_II: 'rgba(16, 185, 129, 0.6)',
|
||||
ZONE_III: 'rgba(245, 158, 11, 0.6)',
|
||||
ZONE_IV: 'rgba(239, 68, 68, 0.6)',
|
||||
};
|
||||
|
||||
|
||||
const fillColor = [
|
||||
'match', ['get', 'id'],
|
||||
'ZONE_I', ZONE_FILL.ZONE_I,
|
||||
'ZONE_II', ZONE_FILL.ZONE_II,
|
||||
'ZONE_III', ZONE_FILL.ZONE_III,
|
||||
'ZONE_IV', ZONE_FILL.ZONE_IV,
|
||||
'rgba(0,0,0,0)',
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
|
||||
const lineColor = [
|
||||
'match', ['get', 'id'],
|
||||
'ZONE_I', ZONE_LINE.ZONE_I,
|
||||
'ZONE_II', ZONE_LINE.ZONE_II,
|
||||
'ZONE_III', ZONE_LINE.ZONE_III,
|
||||
'ZONE_IV', ZONE_LINE.ZONE_IV,
|
||||
'rgba(0,0,0,0)',
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
|
||||
export function FishingZoneLayer() {
|
||||
return (
|
||||
<Source id="fishing-zones" type="geojson" data={fishingZonesData as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
id="fishing-zone-fill"
|
||||
type="fill"
|
||||
paint={{ 'fill-color': fillColor, 'fill-opacity': 1 }}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-zone-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': lineColor,
|
||||
'line-opacity': 1,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
);
|
||||
}
|
||||
1055
frontend/src/components/korea/FleetClusterLayer.tsx
Normal file
1055
frontend/src/components/korea/FleetClusterLayer.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { GOV_BUILDINGS } from '../../data/govBuildings';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { GovBuilding } from '../../data/govBuildings';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -18,79 +16,48 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
|
||||
defense: { icon: '🛡️', label: '국방부', color: '#dc2626' },
|
||||
};
|
||||
|
||||
export function GovBuildingLayer() {
|
||||
const [selected, setSelected] = useState<GovBuilding | null>(null);
|
||||
interface Props {
|
||||
selected: GovBuilding | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GovBuildingLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<>
|
||||
{GOV_BUILDINGS.map(g => {
|
||||
const ts = TYPE_STYLE[g.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Marker key={g.id} longitude={g.lng} latitude={g.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(g); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { KOREAN_AIRPORTS } from '../../services/airports';
|
||||
import type { KoreanAirport } from '../../services/airports';
|
||||
|
||||
const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: string; label: string }> = {
|
||||
@ -12,100 +10,72 @@ const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: stri
|
||||
TW: { intl: '#10b981', domestic: '#059669', flag: '🇹🇼', label: '대만' },
|
||||
};
|
||||
|
||||
function getColor(ap: KoreanAirport) {
|
||||
const cc = COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
|
||||
function getColor(ap: KoreanAirport): string {
|
||||
const cc = COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
|
||||
return ap.intl ? cc.intl : cc.domestic;
|
||||
}
|
||||
|
||||
function getCountryInfo(ap: KoreanAirport) {
|
||||
return COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
|
||||
return COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
|
||||
}
|
||||
|
||||
export function KoreaAirportLayer() {
|
||||
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
||||
interface Props {
|
||||
selected: KoreanAirport | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KoreaAirportLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
const color = getColor(selected);
|
||||
const info = getCountryInfo(selected);
|
||||
return (
|
||||
<>
|
||||
{KOREAN_AIRPORTS.map(ap => {
|
||||
const color = getColor(ap);
|
||||
const size = ap.intl ? 20 : 16;
|
||||
return (
|
||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill={color} stroke="#fff" strokeWidth="0.3" />
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
|
||||
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const color = getColor(selected);
|
||||
const info = getCountryInfo(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{info.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
{selected.intl && (
|
||||
<span style={{
|
||||
background: color, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span style={{
|
||||
background: '#555', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
|
||||
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{info.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
{selected.intl && (
|
||||
<span style={{
|
||||
background: color, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span style={{
|
||||
background: '#555', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
|
||||
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,34 +1,40 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { Map, NavigationControl, Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
|
||||
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
|
||||
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
|
||||
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
import { SubmarineCableLayer } from './SubmarineCableLayer';
|
||||
import { CctvLayer } from './CctvLayer';
|
||||
import { KoreaAirportLayer } from './KoreaAirportLayer';
|
||||
import { CoastGuardLayer } from './CoastGuardLayer';
|
||||
import { NavWarningLayer } from './NavWarningLayer';
|
||||
// 정적 레이어들은 useStaticDeckLayers로 전환됨
|
||||
import { OsintMapLayer } from './OsintMapLayer';
|
||||
import { EezLayer } from './EezLayer';
|
||||
import { PiracyLayer } from './PiracyLayer';
|
||||
import { WindFarmLayer } from './WindFarmLayer';
|
||||
import { PortLayer } from './PortLayer';
|
||||
import { MilitaryBaseLayer } from './MilitaryBaseLayer';
|
||||
import { GovBuildingLayer } from './GovBuildingLayer';
|
||||
import { NKLaunchLayer } from './NKLaunchLayer';
|
||||
import { NKMissileEventLayer } from './NKMissileEventLayer';
|
||||
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
||||
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||
import { HazardFacilityLayer } from './HazardFacilityLayer';
|
||||
import { CnFacilityLayer } from './CnFacilityLayer';
|
||||
import { JpFacilityLayer } from './JpFacilityLayer';
|
||||
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
|
||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||
import { FleetClusterLayer } from './FleetClusterLayer';
|
||||
import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer';
|
||||
import { FishingZoneLayer } from './FishingZoneLayer';
|
||||
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import { fetchKoreaInfra } from '../../services/infra';
|
||||
import type { PowerFacility } from '../../services/infra';
|
||||
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
||||
import type { OsintItem } from '../../services/osint';
|
||||
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
export interface KoreaFiltersState {
|
||||
@ -42,6 +48,7 @@ export interface KoreaFiltersState {
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
allShips?: Ship[];
|
||||
aircraft: Aircraft[];
|
||||
satellites: SatellitePosition[];
|
||||
layers: Record<string, boolean>;
|
||||
@ -52,6 +59,7 @@ interface Props {
|
||||
cableWatchSuspects: Set<string>;
|
||||
dokdoWatchSuspects: Set<string>;
|
||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
}
|
||||
|
||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||
@ -124,21 +132,356 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
||||
ferryWatch: 'filters.ferryWatchMonitor',
|
||||
};
|
||||
|
||||
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
||||
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
|
||||
setFlyToTarget(null);
|
||||
}
|
||||
}, [flyToTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAnalysisMmsi) setTrackCoords(null);
|
||||
}, [selectedAnalysisMmsi]);
|
||||
|
||||
const handleAnalysisShipSelect = useCallback((mmsi: string) => {
|
||||
setSelectedAnalysisMmsi(mmsi);
|
||||
const ship = (allShips ?? ships).find(s => s.mmsi === mmsi);
|
||||
if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 });
|
||||
}, [allShips, ships]);
|
||||
|
||||
const handleTrackLoad = useCallback((_mmsi: string, coords: [number, number][]) => {
|
||||
setTrackCoords(coords);
|
||||
}, []);
|
||||
|
||||
const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => {
|
||||
mapRef.current?.fitBounds(
|
||||
[[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
|
||||
{ padding: 60, duration: 1500 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향
|
||||
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
|
||||
const zoomScale = useMemo(() => {
|
||||
if (zoomLevel <= 4) return 0.8;
|
||||
if (zoomLevel <= 5) return 0.9;
|
||||
if (zoomLevel <= 6) return 1.0;
|
||||
if (zoomLevel <= 7) return 1.2;
|
||||
if (zoomLevel <= 8) return 1.5;
|
||||
if (zoomLevel <= 9) return 1.8;
|
||||
if (zoomLevel <= 10) return 2.2;
|
||||
if (zoomLevel <= 11) return 2.5;
|
||||
if (zoomLevel <= 12) return 2.8;
|
||||
if (zoomLevel <= 13) return 3.5;
|
||||
return 4.2;
|
||||
}, [zoomLevel]);
|
||||
|
||||
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
|
||||
const illegalFishingData = useMemo(() => {
|
||||
if (!koreaFilters.illegalFishing) return [];
|
||||
return (allShips ?? ships).filter(s => {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (mtCat !== 'fishing' || s.flag === 'KR') return false;
|
||||
return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE';
|
||||
}).slice(0, 200);
|
||||
}, [koreaFilters.illegalFishing, allShips, ships]);
|
||||
|
||||
const illegalFishingLayer = useMemo(() => new ScatterplotLayer({
|
||||
id: 'illegal-fishing-highlight',
|
||||
data: illegalFishingData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 800 * zoomScale,
|
||||
getFillColor: [239, 68, 68, 40],
|
||||
getLineColor: [239, 68, 68, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'meters',
|
||||
lineWidthUnits: 'pixels',
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
|
||||
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
|
||||
id: 'illegal-fishing-labels',
|
||||
data: illegalFishingData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name || d.mmsi,
|
||||
getSize: 10 * zoomScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
|
||||
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
|
||||
const zoneLabelsLayer = useMemo(() => {
|
||||
if (!koreaFilters.illegalFishing) return null;
|
||||
const data = (fishingZonesData as GeoJSON.FeatureCollection).features.map(f => {
|
||||
const geom = f.geometry as GeoJSON.MultiPolygon;
|
||||
let sLng = 0, sLat = 0, n = 0;
|
||||
for (const poly of geom.coordinates) {
|
||||
for (const ring of poly) {
|
||||
for (const [lng, lat] of ring) {
|
||||
sLng += lng; sLat += lat; n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: (f.properties as { name: string }).name,
|
||||
lng: n > 0 ? sLng / n : 0,
|
||||
lat: n > 0 ? sLat / n : 0,
|
||||
};
|
||||
});
|
||||
return new TextLayer({
|
||||
id: 'fishing-zone-labels',
|
||||
data,
|
||||
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
|
||||
getText: (d: { name: string }) => d.name,
|
||||
getSize: 12 * zoomScale,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 3,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
});
|
||||
}, [koreaFilters.illegalFishing, zoomScale]);
|
||||
|
||||
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
|
||||
const staticDeckLayers = useStaticDeckLayers({
|
||||
ports: layers.ports ?? false,
|
||||
coastGuard: layers.coastGuard ?? false,
|
||||
windFarm: layers.windFarm ?? false,
|
||||
militaryBases: layers.militaryBases ?? false,
|
||||
govBuildings: layers.govBuildings ?? false,
|
||||
airports: layers.airports ?? false,
|
||||
navWarning: layers.navWarning ?? false,
|
||||
nkLaunch: layers.nkLaunch ?? false,
|
||||
nkMissile: layers.nkMissile ?? false,
|
||||
piracy: layers.piracy ?? false,
|
||||
infra: layers.infra ?? false,
|
||||
infraFacilities: infra,
|
||||
hazardTypes: [
|
||||
...(layers.hazardPetrochemical ? ['petrochemical' as const] : []),
|
||||
...(layers.hazardLng ? ['lng' as const] : []),
|
||||
...(layers.hazardOilTank ? ['oilTank' as const] : []),
|
||||
...(layers.hazardPort ? ['hazardPort' as const] : []),
|
||||
...(layers.energyNuclear ? ['nuclear' as const] : []),
|
||||
...(layers.energyThermal ? ['thermal' as const] : []),
|
||||
...(layers.industryShipyard ? ['shipyard' as const] : []),
|
||||
...(layers.industryWastewater ? ['wastewater' as const] : []),
|
||||
...(layers.industryHeavy ? ['heavyIndustry' as const] : []),
|
||||
],
|
||||
cnPower: !!layers.cnPower,
|
||||
cnMilitary: !!layers.cnMilitary,
|
||||
jpPower: !!layers.jpPower,
|
||||
jpMilitary: !!layers.jpMilitary,
|
||||
onPick: (info) => setStaticPickInfo(info),
|
||||
sizeScale: zoomScale,
|
||||
});
|
||||
|
||||
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
|
||||
const selectedGearLayers = useMemo(() => {
|
||||
if (!selectedGearData) return [];
|
||||
const { parent, gears, groupName } = selectedGearData;
|
||||
const layers = [];
|
||||
|
||||
// 어구 위치 — 주황 원형 마커
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'selected-gear-items',
|
||||
data: gears,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getRadius: 6 * zoomScale,
|
||||
getFillColor: [249, 115, 22, 180],
|
||||
getLineColor: [255, 255, 255, 220],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 1.5,
|
||||
}));
|
||||
|
||||
// 어구 이름 라벨
|
||||
layers.push(new TextLayer({
|
||||
id: 'selected-gear-labels',
|
||||
data: gears,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => d.name || d.mmsi,
|
||||
getSize: 9 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}));
|
||||
|
||||
// 모선 강조 — 큰 원 + 라벨
|
||||
if (parent) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'selected-gear-parent',
|
||||
data: [parent],
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getRadius: 14 * zoomScale,
|
||||
getFillColor: [249, 115, 22, 80],
|
||||
getLineColor: [249, 115, 22, 255],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 3,
|
||||
}));
|
||||
layers.push(new TextLayer({
|
||||
id: 'selected-gear-parent-label',
|
||||
data: [parent],
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`,
|
||||
getSize: 11 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 18],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 3,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}));
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [selectedGearData, zoomScale]);
|
||||
|
||||
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
|
||||
const selectedFleetLayers = useMemo(() => {
|
||||
if (!selectedFleetData) return [];
|
||||
const { ships: fleetShips, clusterId } = selectedFleetData;
|
||||
if (fleetShips.length === 0) return [];
|
||||
|
||||
// HSL→RGB 인라인 변환 (선단 색상)
|
||||
const hue = (clusterId * 137) % 360;
|
||||
const h = hue / 360; const s = 0.7; const l = 0.6;
|
||||
const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; return t < 1/6 ? p + (q-p)*6*t : t < 1/2 ? q : t < 2/3 ? p + (q-p)*(2/3-t)*6 : p; };
|
||||
const q = l < 0.5 ? l * (1+s) : l + s - l*s; const p = 2*l - q;
|
||||
const r = Math.round(hue2rgb(p, q, h + 1/3) * 255);
|
||||
const g = Math.round(hue2rgb(p, q, h) * 255);
|
||||
const b = Math.round(hue2rgb(p, q, h - 1/3) * 255);
|
||||
const color: [number, number, number, number] = [r, g, b, 255];
|
||||
const fillColor: [number, number, number, number] = [r, g, b, 80];
|
||||
|
||||
const result: Layer[] = [];
|
||||
|
||||
// 소속 선박 — 강조 원형
|
||||
result.push(new ScatterplotLayer({
|
||||
id: 'selected-fleet-items',
|
||||
data: fleetShips,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getRadius: 8 * zoomScale,
|
||||
getFillColor: fillColor,
|
||||
getLineColor: color,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
}));
|
||||
|
||||
// 소속 선박 이름 라벨
|
||||
result.push(new TextLayer({
|
||||
id: 'selected-fleet-labels',
|
||||
data: fleetShips,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => {
|
||||
const dto = vesselAnalysis?.analysisMap.get(d.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role;
|
||||
const prefix = role === 'LEADER' ? '★ ' : '';
|
||||
return `${prefix}${d.name || d.mmsi}`;
|
||||
},
|
||||
getSize: 9 * zoomScale,
|
||||
getColor: color,
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}));
|
||||
|
||||
// 리더 선박 추가 강조 (큰 외곽 링)
|
||||
const leaders = fleetShips.filter(s => {
|
||||
const dto = vesselAnalysis?.analysisMap.get(s.mmsi);
|
||||
return dto?.algorithms.fleetRole.isLeader;
|
||||
});
|
||||
if (leaders.length > 0) {
|
||||
result.push(new ScatterplotLayer({
|
||||
id: 'selected-fleet-leaders',
|
||||
data: leaders,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getRadius: 16 * zoomScale,
|
||||
getFillColor: [0, 0, 0, 0],
|
||||
getLineColor: color,
|
||||
stroked: true,
|
||||
filled: false,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 3,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis]);
|
||||
|
||||
// 분석 결과 deck.gl 레이어
|
||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||
: koreaFilters.darkVessel ? 'darkVessel'
|
||||
: layers.cnFishing ? 'cnFishing'
|
||||
: null;
|
||||
|
||||
const analysisDeckLayers = useAnalysisDeckLayers(
|
||||
vesselAnalysis?.analysisMap ?? new Map(),
|
||||
allShips ?? ships,
|
||||
analysisActiveFilter,
|
||||
zoomScale,
|
||||
);
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -203,7 +546,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
@ -265,32 +608,240 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
||||
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.cables && <SubmarineCableLayer />}
|
||||
{layers.cctv && <CctvLayer />}
|
||||
{layers.windFarm && <WindFarmLayer />}
|
||||
{layers.ports && <PortLayer />}
|
||||
{layers.militaryBases && <MilitaryBaseLayer />}
|
||||
{layers.govBuildings && <GovBuildingLayer />}
|
||||
{layers.nkLaunch && <NKLaunchLayer />}
|
||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} />}
|
||||
{layers.hazardPetrochemical && <HazardFacilityLayer type="petrochemical" />}
|
||||
{layers.hazardLng && <HazardFacilityLayer type="lng" />}
|
||||
{layers.hazardOilTank && <HazardFacilityLayer type="oilTank" />}
|
||||
{layers.hazardPort && <HazardFacilityLayer type="hazardPort" />}
|
||||
{layers.energyNuclear && <HazardFacilityLayer type="nuclear" />}
|
||||
{layers.energyThermal && <HazardFacilityLayer type="thermal" />}
|
||||
{layers.industryShipyard && <HazardFacilityLayer type="shipyard" />}
|
||||
{layers.industryWastewater && <HazardFacilityLayer type="wastewater" />}
|
||||
{layers.industryHeavy && <HazardFacilityLayer type="heavyIndustry" />}
|
||||
{layers.cnPower && <CnFacilityLayer type="power" />}
|
||||
{layers.cnMilitary && <CnFacilityLayer type="military" />}
|
||||
{layers.jpPower && <JpFacilityLayer type="power" />}
|
||||
{layers.jpMilitary && <JpFacilityLayer type="military" />}
|
||||
{layers.airports && <KoreaAirportLayer />}
|
||||
{layers.coastGuard && <CoastGuardLayer />}
|
||||
{layers.navWarning && <NavWarningLayer />}
|
||||
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
||||
{(koreaFilters.illegalFishing || layers.cnFishing) && <FishingZoneLayer />}
|
||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
|
||||
{layers.cnFishing && (
|
||||
<FleetClusterLayer
|
||||
ships={allShips ?? ships}
|
||||
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
||||
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onFleetZoom={handleFleetZoom}
|
||||
onSelectedGearChange={setSelectedGearData}
|
||||
onSelectedFleetChange={setSelectedFleetData}
|
||||
/>
|
||||
)}
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||
<AnalysisOverlay
|
||||
ships={allShips ?? ships}
|
||||
analysisMap={vesselAnalysis.analysisMap}
|
||||
clusters={vesselAnalysis.clusters}
|
||||
activeFilter={analysisActiveFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
|
||||
<DeckGLOverlay layers={[
|
||||
...staticDeckLayers,
|
||||
illegalFishingLayer,
|
||||
illegalFishingLabelLayer,
|
||||
zoneLabelsLayer,
|
||||
...selectedGearLayers,
|
||||
...selectedFleetLayers,
|
||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||
].filter(Boolean)} />
|
||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||
{staticPickInfo && (() => {
|
||||
const obj = staticPickInfo.object;
|
||||
const kind = staticPickInfo.kind;
|
||||
const lat = obj.lat ?? obj.launchLat ?? 0;
|
||||
const lng = obj.lng ?? obj.launchLng ?? 0;
|
||||
if (!lat || !lng) return null;
|
||||
|
||||
// ── kind + subType 기반 메타 결정 ──
|
||||
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
|
||||
hazard: {
|
||||
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
|
||||
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
|
||||
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
|
||||
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
|
||||
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
|
||||
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
|
||||
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
|
||||
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
|
||||
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
|
||||
},
|
||||
overseas: {
|
||||
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
|
||||
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
|
||||
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
|
||||
},
|
||||
militaryBase: {
|
||||
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
|
||||
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
|
||||
},
|
||||
govBuilding: {
|
||||
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
|
||||
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
|
||||
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
|
||||
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
|
||||
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
|
||||
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
|
||||
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
|
||||
},
|
||||
nkLaunch: {
|
||||
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
|
||||
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
|
||||
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
|
||||
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
|
||||
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
|
||||
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
|
||||
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
|
||||
},
|
||||
coastGuard: {
|
||||
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
|
||||
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
|
||||
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
|
||||
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
|
||||
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
|
||||
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
|
||||
},
|
||||
airport: {
|
||||
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
|
||||
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
|
||||
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
|
||||
},
|
||||
navWarning: {
|
||||
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
|
||||
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
|
||||
info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' },
|
||||
},
|
||||
piracy: {
|
||||
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
|
||||
high: { icon: '☠️', color: '#f97316', label: '고위험' },
|
||||
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
|
||||
},
|
||||
};
|
||||
|
||||
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
|
||||
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
|
||||
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
|
||||
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
|
||||
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
|
||||
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
|
||||
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
|
||||
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
|
||||
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
|
||||
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
|
||||
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
|
||||
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
|
||||
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
|
||||
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
|
||||
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
|
||||
};
|
||||
|
||||
// subType 키 결정
|
||||
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
|
||||
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
|
||||
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
|
||||
|
||||
// 국가 플래그
|
||||
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
|
||||
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
|
||||
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
|
||||
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
|
||||
|
||||
// 이름 결정
|
||||
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
|
||||
|
||||
return (
|
||||
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
||||
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
|
||||
maxWidth="280px" className="gl-popup"
|
||||
>
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
{/* 컬러 헤더 */}
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
|
||||
<span>{meta.icon}</span> {title}
|
||||
</div>
|
||||
{/* 배지 행 */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#000',
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
{flag && (
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||
{flag} {countryName}
|
||||
</span>
|
||||
)}
|
||||
{kind === 'hazard' && (
|
||||
<span style={{
|
||||
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
}}>⚠️ 위험시설</span>
|
||||
)}
|
||||
{kind === 'port' && (
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||
{obj.type === 'major' ? '주요항' : '중소항'}
|
||||
</span>
|
||||
)}
|
||||
{kind === 'airport' && obj.intl && (
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>국제선</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 설명 */}
|
||||
{obj.description && (
|
||||
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
|
||||
)}
|
||||
{obj.detail && (
|
||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
|
||||
)}
|
||||
{obj.note && (
|
||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
|
||||
)}
|
||||
{/* 필드 그리드 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{obj.operator && <div><span className="popup-label">운영: </span>{obj.operator}</div>}
|
||||
{obj.capacity && <div><span className="popup-label">규모: </span><strong>{obj.capacity}</strong></div>}
|
||||
{obj.output && <div><span className="popup-label">출력: </span><strong>{obj.output}</strong></div>}
|
||||
{obj.source && <div><span className="popup-label">연료: </span>{obj.source}</div>}
|
||||
{obj.capacityMW && <div><span className="popup-label">용량: </span><strong>{obj.capacityMW}MW</strong></div>}
|
||||
{obj.turbines && <div><span className="popup-label">터빈: </span>{obj.turbines}기</div>}
|
||||
{obj.status && <div><span className="popup-label">상태: </span>{obj.status}</div>}
|
||||
{obj.year && <div><span className="popup-label">연도: </span>{obj.year}년</div>}
|
||||
{obj.region && <div><span className="popup-label">지역: </span>{obj.region}</div>}
|
||||
{obj.org && <div><span className="popup-label">기관: </span>{obj.org}</div>}
|
||||
{obj.area && <div><span className="popup-label">해역: </span>{obj.area}</div>}
|
||||
{obj.altitude && <div><span className="popup-label">고도: </span>{obj.altitude}</div>}
|
||||
{obj.address && <div><span className="popup-label">주소: </span>{obj.address}</div>}
|
||||
{obj.recentUse && <div><span className="popup-label">최근 사용: </span>{obj.recentUse}</div>}
|
||||
{obj.recentIncidents != null && <div><span className="popup-label">최근 1년: </span><strong>{obj.recentIncidents}건</strong></div>}
|
||||
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
|
||||
{kind === 'nkMissile' && (
|
||||
<>
|
||||
{obj.typeKo && <div><span className="popup-label">미사일: </span>{obj.typeKo}</div>}
|
||||
{obj.date && <div><span className="popup-label">발사일: </span>{obj.date} {obj.time}</div>}
|
||||
{obj.distanceKm && <div><span className="popup-label">사거리: </span>{obj.distanceKm}km</div>}
|
||||
{obj.altitudeKm && <div><span className="popup-label">최고고도: </span>{obj.altitudeKm}km</div>}
|
||||
{obj.flightMin && <div><span className="popup-label">비행시간: </span>{obj.flightMin}분</div>}
|
||||
{obj.launchNameKo && <div><span className="popup-label">발사지: </span>{obj.launchNameKo}</div>}
|
||||
</>
|
||||
)}
|
||||
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
|
||||
<div><span className="popup-label">영문: </span>{obj.name}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
||||
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||
{layers.eez && <EezLayer />}
|
||||
{layers.piracy && <PiracyLayer />}
|
||||
|
||||
{/* Filter Status Banner */}
|
||||
{(() => {
|
||||
@ -346,6 +897,42 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 분석 선박 항적 — tracks API 응답 기반 */}
|
||||
{trackCoords && trackCoords.length > 1 && (
|
||||
<Source id="analysis-trail" type="geojson" data={{
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: trackCoords,
|
||||
},
|
||||
}],
|
||||
}}>
|
||||
<Layer id="analysis-trail-line" type="line" paint={{
|
||||
'line-color': '#00e5ff',
|
||||
'line-width': 2.5,
|
||||
'line-opacity': 0.8,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* AI Analysis Stats Panel — 항상 표시 */}
|
||||
{vesselAnalysis && (
|
||||
<AnalysisStatsPanel
|
||||
stats={vesselAnalysis.stats}
|
||||
lastUpdated={vesselAnalysis.lastUpdated}
|
||||
isLoading={vesselAnalysis.isLoading}
|
||||
analysisMap={vesselAnalysis.analysisMap}
|
||||
ships={allShips ?? ships}
|
||||
allShips={allShips ?? ships}
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onTrackLoad={handleTrackLoad}
|
||||
onExpandedChange={setAnalysisPanelOpen}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { MILITARY_BASES } from '../../data/militaryBases';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { MilitaryBase } from '../../data/militaryBases';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -18,91 +16,49 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
|
||||
joint: { icon: '⭐', label: '합동기지', color: '#a78bfa' },
|
||||
};
|
||||
|
||||
function _MilIcon({ type, size = 16 }: { type: string; size?: number }) {
|
||||
const ts = TYPE_STYLE[type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,8 22,16 12,22 2,16 2,8" fill="rgba(0,0,0,0.6)" stroke={ts.color} strokeWidth="1.5" />
|
||||
<text x="12" y="14" textAnchor="middle" fontSize="9" fill={ts.color}>{ts.icon}</text>
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: MilitaryBase | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MilitaryBaseLayer() {
|
||||
const [selected, setSelected] = useState<MilitaryBase | null>(null);
|
||||
|
||||
export function MilitaryBaseLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<>
|
||||
{MILITARY_BASES.map(base => {
|
||||
const _cs = COUNTRY_STYLE[base.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[base.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<Marker key={base.id} longitude={base.lng} latitude={base.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(base); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">시설명 : </span><strong>{selected.name}</strong></div>
|
||||
<div><span className="popup-label">유형 : </span>{ts.label}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">시설명 : </span><strong>{selected.name}</strong></div>
|
||||
<div><span className="popup-label">유형 : </span>{ts.label}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,89 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||
|
||||
export function NKLaunchLayer() {
|
||||
const [selected, setSelected] = useState<NKLaunchSite | null>(null);
|
||||
interface Props {
|
||||
selected: NKLaunchSite | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NKLaunchLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const meta = NK_LAUNCH_TYPE_META[selected.type];
|
||||
return (
|
||||
<>
|
||||
{NK_LAUNCH_SITES.map(site => {
|
||||
const meta = NK_LAUNCH_TYPE_META[site.type];
|
||||
const isArtillery = site.type === 'artillery' || site.type === 'mlrs';
|
||||
const size = isArtillery ? 14 : 18;
|
||||
return (
|
||||
<Marker key={site.id} longitude={site.lng} latitude={site.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(site); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${meta.color}aa)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: isArtillery ? '50%' : 4,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: `2px solid ${meta.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: isArtillery ? 8 : 10,
|
||||
}}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: meta.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const meta = NK_LAUNCH_TYPE_META[selected.type];
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#f97316', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
북한
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
{selected.recentUse && (
|
||||
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
|
||||
최근: {selected.recentUse}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#f97316', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
북한
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
{selected.recentUse && (
|
||||
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
|
||||
최근: {selected.recentUse}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { useMemo } from 'react';
|
||||
import { Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
||||
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||
import type { Ship } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
|
||||
function isToday(dateStr: string): boolean {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return dateStr === today;
|
||||
}
|
||||
|
||||
function getMissileColor(type: string): string {
|
||||
if (type.includes('ICBM')) return '#dc2626';
|
||||
if (type.includes('IRBM')) return '#ef4444';
|
||||
@ -27,11 +22,11 @@ function distKm(lat1: number, lng1: number, lat2: number, lng2: number): number
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
selected: NKMissileEvent | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NKMissileEventLayer({ ships }: Props) {
|
||||
const [selected, setSelected] = useState<NKMissileEvent | null>(null);
|
||||
|
||||
export function NKMissileEventLayer({ ships, selected, onClose }: Props) {
|
||||
const lineGeoJSON = useMemo(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: NK_MISSILE_EVENTS.map(ev => ({
|
||||
@ -51,7 +46,7 @@ export function NKMissileEventLayer({ ships }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 궤적 라인 */}
|
||||
{/* 궤적 라인 — MapLibre Source/Layer 유지 */}
|
||||
<Source id="nk-missile-lines" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="nk-missile-line-layer"
|
||||
@ -65,62 +60,12 @@ export function NKMissileEventLayer({ ships }: Props) {
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 발사 지점 (▲) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`launch-${ev.id}`} longitude={ev.launchLng} latitude={ev.launchLat} anchor="center">
|
||||
<div style={{ filter: `drop-shadow(0 0 4px ${color}aa)`, opacity: today ? 1 : 0.35 }}>
|
||||
<svg width={12} height={12} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,20 2,20" fill={color} stroke="#fff" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 (✕ + 정보 라벨) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`impact-${ev.id}`} longitude={ev.impactLng} latitude={ev.impactLat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ev); }}>
|
||||
<div className="cursor-pointer flex flex-col items-center" style={{
|
||||
filter: `drop-shadow(0 0 ${today ? '6px' : '3px'} ${color})`,
|
||||
opacity: today ? 1 : 0.4,
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
<svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="7" y1="7" x2="17" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="17" y1="7" x2="7" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
{today && (
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke={color} strokeWidth="1" opacity="0.4">
|
||||
<animate attributeName="r" values="10;18;10" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;0;0.4" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 5, color, fontWeight: 700, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{ev.date.slice(5)} {ev.time} ← {ev.launchNameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 팝업 */}
|
||||
{selected && (() => {
|
||||
const color = getMissileColor(selected.type);
|
||||
return (
|
||||
<Popup longitude={selected.impactLng} latitude={selected.impactLat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 260 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
|
||||
import { NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
|
||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning';
|
||||
|
||||
const LEVEL_COLOR: Record<NavWarningLevel, string> = {
|
||||
@ -19,112 +18,68 @@ const ORG_COLOR: Record<TrainingOrg, string> = {
|
||||
'국과연': '#eab308',
|
||||
};
|
||||
|
||||
function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) {
|
||||
const color = ORG_COLOR[org];
|
||||
|
||||
if (level === 'danger') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="12" y1="9" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: NavWarning | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NavWarningLayer() {
|
||||
const [selected, setSelected] = useState<NavWarning | null>(null);
|
||||
export function NavWarningLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{NAV_WARNINGS.map(w => {
|
||||
const color = ORG_COLOR[w.org];
|
||||
const size = w.level === 'danger' ? 16 : 14;
|
||||
return (
|
||||
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 4px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<WarningIcon level={w.level} org={w.org} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="whitespace-nowrap font-bold tracking-wide">
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
color: '#fff',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
margin: '-10px -10px 0',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
}}>
|
||||
{selected.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
color: '#fff',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3,
|
||||
}}>
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
||||
>{t('navWarning.khoaLink')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
color: '#fff',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
margin: '-10px -10px 0',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
}}>
|
||||
{selected.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
color: '#fff',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3,
|
||||
}}>
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
||||
>{t('navWarning.khoaLink')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,95 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
|
||||
import { PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
|
||||
import type { PiracyZone } from '../../services/piracy';
|
||||
|
||||
function SkullIcon({ color, size }: { color: string; size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
||||
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
||||
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: PiracyZone | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PiracyLayer() {
|
||||
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
||||
export function PiracyLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{PIRACY_ZONES.map(zone => {
|
||||
const color = PIRACY_LEVEL_COLOR[zone.level];
|
||||
const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20;
|
||||
return (
|
||||
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
||||
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
}} className="flex flex-col items-center">
|
||||
<SkullIcon color={color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 7, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
|
||||
{PIRACY_LEVEL_LABEL[zone.level]}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[260px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||
<span className="text-sm">☠️</span>
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[260px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||
<span className="text-sm">☠️</span>
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.name}
|
||||
</span>
|
||||
{selected.recentIncidents != null && (
|
||||
<span style={{
|
||||
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.name}
|
||||
</span>
|
||||
{selected.recentIncidents != null && (
|
||||
<span style={{
|
||||
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-[#999]">
|
||||
{selected.detail}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-[#999]">
|
||||
{selected.detail}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { Port } from '../../data/ports';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -15,87 +13,51 @@ function getStyle(p: Port) {
|
||||
return COUNTRY_STYLE[p.country] || COUNTRY_STYLE.KR;
|
||||
}
|
||||
|
||||
function AnchorIcon({ color, size = 14 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="5" r="2.5" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
<line x1="12" y1="7.5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="7" y1="12" x2="17" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: Port | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PortLayer() {
|
||||
const [selected, setSelected] = useState<Port | null>(null);
|
||||
|
||||
export function PortLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const s = getStyle(selected);
|
||||
return (
|
||||
<>
|
||||
{EAST_ASIA_PORTS.map(p => {
|
||||
const s = getStyle(p);
|
||||
const size = p.type === 'major' ? 16 : 12;
|
||||
return (
|
||||
<Marker key={p.id} longitude={p.lng} latitude={p.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(p); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 2px ${s.color}88)` }}
|
||||
>
|
||||
<AnchorIcon color={s.color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color: s.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{p.nameKo.replace('항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const s = getStyle(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{s.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>⚓ {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: s.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.type === 'major' ? '주요항만' : '항만'}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">항구 : </span><strong>{selected.nameKo}</strong></div>
|
||||
<div><span className="popup-label">영문 : </span>{selected.name}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{s.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>⚓ {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: s.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.type === 'major' ? '주요항만' : '항만'}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">항구 : </span><strong>{selected.nameKo}</strong></div>
|
||||
<div><span className="popup-label">영문 : </span>{selected.name}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,26 @@ export function SubmarineCableLayer() {
|
||||
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
|
||||
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
|
||||
|
||||
// 날짜변경선(180도) 보정: 연속 좌표가 180도를 넘으면 경도를 연속으로 만듦
|
||||
// 예: [170, lat] → [-170, lat] 를 [170, lat] → [190, lat] 로 변환
|
||||
function fixDateline(route: number[][]): number[][] {
|
||||
const fixed: number[][] = [];
|
||||
for (let i = 0; i < route.length; i++) {
|
||||
const [lng, lat] = route[i];
|
||||
if (i === 0) {
|
||||
fixed.push([lng, lat]);
|
||||
continue;
|
||||
}
|
||||
const prevLng = fixed[i - 1][0];
|
||||
let newLng = lng;
|
||||
// 이전 경도와 180도 이상 차이나면 보정
|
||||
while (newLng - prevLng > 180) newLng -= 360;
|
||||
while (prevLng - newLng > 180) newLng += 360;
|
||||
fixed.push([newLng, lat]);
|
||||
}
|
||||
return fixed;
|
||||
}
|
||||
|
||||
// Build GeoJSON for all cables
|
||||
const geojson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
@ -19,7 +39,7 @@ export function SubmarineCableLayer() {
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: cable.route,
|
||||
coordinates: fixDateline(cable.route),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
@ -1,27 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { KOREA_WIND_FARMS } from '../../data/windFarms';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { WindFarm } from '../../data/windFarms';
|
||||
|
||||
const COLOR = '#0891b2';
|
||||
|
||||
export function WindTurbineIcon({ size = 20, color }: { size?: number; color?: string }) {
|
||||
const c = color || COLOR;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" fill="none">
|
||||
<path d="M15 14 L14.2 29 L17.8 29 L17 14 Z" fill={c} opacity="0.7" />
|
||||
<rect x="11" y="28.5" width="10" height="2" rx="1" fill={c} opacity="0.5" />
|
||||
<ellipse cx="16" cy="12" rx="2.5" ry="1.5" fill={c} />
|
||||
<circle cx="16" cy="12" r="1.2" fill="#fff" opacity="0.9" />
|
||||
<circle cx="16" cy="12" r="0.6" fill={c} />
|
||||
<path d="M16 12 L15 1.5 Q16 0.5 17 1.5 Z" fill={c} opacity="0.85" />
|
||||
<path d="M16 12 L24.5 18 Q24 19.5 22.5 18.5 Z" fill={c} opacity="0.85" />
|
||||
<path d="M16 12 L7.5 18 Q8 19.5 9.5 18.5 Z" fill={c} opacity="0.85" />
|
||||
<path d="M3 30 Q5.5 28 8 30 Q10.5 32 13 30" stroke={c} strokeWidth="0.8" fill="none" opacity="0.4" />
|
||||
<path d="M19 30 Q21.5 28 24 30 Q26.5 32 29 30" stroke={c} strokeWidth="0.8" fill="none" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const COLOR = '#00bcd4';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'운영중': '#22c55e',
|
||||
@ -29,85 +9,52 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
'계획': '#64748b',
|
||||
};
|
||||
|
||||
export function WindFarmLayer() {
|
||||
const [selected, setSelected] = useState<WindFarm | null>(null);
|
||||
interface Props {
|
||||
selected: WindFarm | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WindFarmLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{KOREA_WIND_FARMS.map(wf => (
|
||||
<Marker key={wf.id} longitude={wf.lng} latitude={wf.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(wf); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${COLOR}88)` }}
|
||||
>
|
||||
<WindTurbineIcon size={22} />
|
||||
<div style={{
|
||||
fontSize: 6, color: COLOR, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div style={{ background: '#1a1e2e', borderRadius: 6, overflow: 'hidden', minWidth: 200 }}>
|
||||
{/* Header - full width */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'rgba(0,188,212,0.15)', padding: '6px 10px',
|
||||
borderBottom: '1px solid rgba(0,188,212,0.3)',
|
||||
}}>
|
||||
<WindTurbineIcon size={16} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>{selected.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{/* Tags */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'rgba(0,188,212,0.2)', color: COLOR, border: `1px solid ${COLOR}50`,
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
해상풍력
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.06)', color: '#94a3b8',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.region}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11, color: '#cbd5e1' }}>
|
||||
<div><span style={{ color: '#64748b' }}>용량 </span><strong style={{ color: COLOR }}>{selected.capacityMW} MW</strong></div>
|
||||
<div><span style={{ color: '#64748b' }}>터빈 </span><strong>{selected.turbines}기</strong></div>
|
||||
{selected.year && <div><span style={{ color: '#64748b' }}>준공 </span><strong>{selected.year}년</strong></div>}
|
||||
<div><span style={{ color: '#64748b' }}>지역 </span>{selected.region}</div>
|
||||
</div>
|
||||
|
||||
{/* Coordinates */}
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: '#64748b' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🌀</span>
|
||||
<strong>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: COLOR, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
해상풍력
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">용량 : </span><strong>{selected.capacityMW} MW</strong></div>
|
||||
<div><span className="popup-label">터빈 : </span><strong>{selected.turbines}기</strong></div>
|
||||
{selected.year && <div><span className="popup-label">준공 : </span><strong>{selected.year}년</strong></div>}
|
||||
<div><span className="popup-label">지역 : </span>{selected.region}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
22
frontend/src/components/layers/DeckGLOverlay.tsx
Normal file
22
frontend/src/components/layers/DeckGLOverlay.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useControl } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
|
||||
interface Props {
|
||||
layers: Layer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre Map 내부에서 deck.gl 레이어를 GPU 렌더링하는 오버레이.
|
||||
* interleaved 모드: MapLibre 레이어와 deck.gl 레이어가 z-order로 혼합됨.
|
||||
*/
|
||||
export function DeckGLOverlay({ layers }: Props) {
|
||||
const overlay = useControl<MapboxOverlay>(
|
||||
() => new MapboxOverlay({
|
||||
interleaved: true,
|
||||
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
|
||||
}),
|
||||
);
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
@ -1,10 +1,8 @@
|
||||
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';
|
||||
import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { detectFleet } from '../../utils/fleetDetection';
|
||||
import type { FleetConnection } from '../../utils/fleetDetection';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
@ -13,6 +11,7 @@ interface Props {
|
||||
hoveredMmsi?: string | null;
|
||||
focusMmsi?: string | null;
|
||||
onFocusClear?: () => void;
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
}
|
||||
|
||||
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||
@ -362,7 +361,7 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
||||
}
|
||||
|
||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear }: Props) {
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) {
|
||||
const { current: map } = useMap();
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
@ -465,35 +464,48 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
|
||||
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||
|
||||
// 선단 탐지 (중국어선 선택 시 — 성능 최적화: 근처 선박만 전달)
|
||||
const fleet: FleetConnection | null = useMemo(() => {
|
||||
if (!selectedShip || selectedShip.flag !== 'CN') return null;
|
||||
// 0.2도(~12NM) 이내 선박만 필터링하여 전달
|
||||
const nearby = ships.filter(s =>
|
||||
Math.abs(s.lat - selectedShip.lat) < 0.2 &&
|
||||
Math.abs(s.lng - selectedShip.lng) < 0.2
|
||||
);
|
||||
return detectFleet(selectedShip, nearby);
|
||||
}, [selectedShip, ships]);
|
||||
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
|
||||
const selectedFleetMembers = useMemo(() => {
|
||||
if (!selectedMmsi || !analysisMap) return [];
|
||||
const dto = analysisMap.get(selectedMmsi);
|
||||
if (!dto) return [];
|
||||
const clusterId = dto.algorithms.cluster.clusterId;
|
||||
if (clusterId < 0) return [];
|
||||
|
||||
// 선단 연결선 GeoJSON
|
||||
// 같은 cluster_id를 가진 모든 선박
|
||||
const members: { ship: Ship; role: string; roleKo: string }[] = [];
|
||||
for (const [mmsi, d] of analysisMap) {
|
||||
if (d.algorithms.cluster.clusterId !== clusterId) continue;
|
||||
const ship = ships.find(s => s.mmsi === mmsi);
|
||||
if (!ship) continue;
|
||||
const isLeader = d.algorithms.fleetRole.isLeader;
|
||||
members.push({
|
||||
ship,
|
||||
role: d.algorithms.fleetRole.role,
|
||||
roleKo: isLeader ? '본선' : '선단원',
|
||||
});
|
||||
}
|
||||
return members;
|
||||
}, [selectedMmsi, analysisMap, ships]);
|
||||
|
||||
// 선단 연결선 GeoJSON — 선택 선박과 같은 cluster 멤버 연결
|
||||
const fleetLineGeoJson = useMemo(() => {
|
||||
if (!fleet) return { type: 'FeatureCollection' as const, features: [] };
|
||||
if (selectedFleetMembers.length < 2) return { type: 'FeatureCollection' as const, features: [] };
|
||||
// 중심점 계산
|
||||
const cLat = selectedFleetMembers.reduce((s, m) => s + m.ship.lat, 0) / selectedFleetMembers.length;
|
||||
const cLng = selectedFleetMembers.reduce((s, m) => s + m.ship.lng, 0) / selectedFleetMembers.length;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: fleet.members.map(m => ({
|
||||
features: selectedFleetMembers.map(m => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { role: m.role },
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [
|
||||
[fleet.selectedShip.lng, fleet.selectedShip.lat],
|
||||
[m.ship.lng, m.ship.lat],
|
||||
],
|
||||
coordinates: [[cLng, cLat], [m.ship.lng, m.ship.lat]],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [fleet]);
|
||||
}, [selectedFleetMembers]);
|
||||
|
||||
// Carrier labels — only a few, so DOM markers are fine
|
||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
||||
@ -514,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
return (
|
||||
<>
|
||||
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
|
||||
{/* Hovered ship highlight ring */}
|
||||
{/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */}
|
||||
<Layer
|
||||
id="ships-hover-ring"
|
||||
type="circle"
|
||||
filter={['boolean', ['feature-state', 'hovered'], false]}
|
||||
paint={{
|
||||
'circle-radius': 18,
|
||||
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
|
||||
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-width': ['case', ['boolean', ['feature-state', 'hovered'], false], 2, 0],
|
||||
'circle-stroke-opacity': 0.9,
|
||||
}}
|
||||
/>
|
||||
@ -548,7 +559,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
layout={{
|
||||
'visibility': highlightKorean ? 'visible' : 'none',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 9,
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20],
|
||||
'text-offset': [0, 2.2],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
@ -566,7 +577,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': 'ship-triangle',
|
||||
'icon-size': ['get', 'size'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'size'], 0.8],
|
||||
6, ['*', ['get', 'size'], 1.0],
|
||||
8, ['*', ['get', 'size'], 1.5],
|
||||
10, ['*', ['get', 'size'], 2.2],
|
||||
12, ['*', ['get', 'size'], 2.8],
|
||||
13, ['*', ['get', 'size'], 3.5],
|
||||
14, ['*', ['get', 'size'], 4.2],
|
||||
],
|
||||
'icon-rotate': ['get', 'heading'],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
@ -598,8 +617,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Fleet connection lines — 중국어선 클릭 시만 */}
|
||||
{fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && (
|
||||
{/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */}
|
||||
{selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && (
|
||||
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
||||
<Layer
|
||||
id="fleet-line-layer"
|
||||
@ -614,8 +633,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Fleet member markers — 중국어선 클릭 시만 */}
|
||||
{fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => (
|
||||
{/* Fleet member markers — Python cluster 기반 */}
|
||||
{selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
|
||||
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
@ -625,26 +644,27 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
fontSize: 8, color: '#fff', fontWeight: 700,
|
||||
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
|
||||
}}>
|
||||
{m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : '●'}
|
||||
{m.role === 'LEADER' ? 'L' : '●'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
||||
textShadow: '0 0 3px #000', fontWeight: 700, marginTop: -2,
|
||||
}}>
|
||||
{m.roleKo} {m.distanceNm.toFixed(1)}NM
|
||||
{m.roleKo}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup for selected ship */}
|
||||
{selectedShip && (
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleet={fleet} />
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleetGroup={null} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship; onClose: () => void; fleet?: FleetConnection | null }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: any }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const mtType = getMTType(ship);
|
||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||
@ -808,21 +828,20 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet info (중국어선만) */}
|
||||
{fleet && fleet.members.length > 0 && (
|
||||
{/* Fleet info (선단 그룹 소속 시) */}
|
||||
{fleetGroup && fleetGroup.members.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
|
||||
🔗 {fleet.fleetTypeKo} — {fleet.members.length}척 연결
|
||||
🔗 {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결
|
||||
</div>
|
||||
{fleet.members.slice(0, 5).map(m => (
|
||||
{fleetGroup.members.slice(0, 5).map(m => (
|
||||
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
|
||||
<span style={{ color: '#ef4444', fontWeight: 700, minWidth: 55 }}>{m.roleKo}</span>
|
||||
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
|
||||
<span style={{ color: '#f97316' }}>{m.distanceNm.toFixed(1)}NM</span>
|
||||
</div>
|
||||
))}
|
||||
{fleet.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleet.members.length - 5}척</div>
|
||||
{fleetGroup.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleetGroup.members.length - 5}척</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
1
frontend/src/data/zones/fishing-zones-wgs84.json
Normal file
1
frontend/src/data/zones/fishing-zones-wgs84.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/data/zones/특정어업수역Ⅰ.json
Normal file
1
frontend/src/data/zones/특정어업수역Ⅰ.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/data/zones/특정어업수역Ⅱ.json
Normal file
1
frontend/src/data/zones/특정어업수역Ⅱ.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/data/zones/특정어업수역Ⅲ.json
Normal file
1
frontend/src/data/zones/특정어업수역Ⅲ.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/data/zones/특정어업수역Ⅳ.json
Normal file
1
frontend/src/data/zones/특정어업수역Ⅳ.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}
|
||||
187
frontend/src/hooks/useAnalysisDeckLayers.ts
Normal file
187
frontend/src/hooks/useAnalysisDeckLayers.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
dto: VesselAnalysisDto;
|
||||
}
|
||||
|
||||
// RISK_RGBA: [r, g, b, a] 충전색
|
||||
const RISK_RGBA: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 60],
|
||||
HIGH: [249, 115, 22, 50],
|
||||
MEDIUM: [234, 179, 8, 40],
|
||||
};
|
||||
|
||||
// 테두리색
|
||||
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 230],
|
||||
HIGH: [249, 115, 22, 210],
|
||||
MEDIUM: [234, 179, 8, 190],
|
||||
};
|
||||
|
||||
// 픽셀 반경
|
||||
const RISK_SIZE: Record<string, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
MEDIUM: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
|
||||
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
|
||||
*/
|
||||
export function useAnalysisDeckLayers(
|
||||
analysisMap: Map<string, VesselAnalysisDto>,
|
||||
ships: Ship[],
|
||||
activeFilter: string | null,
|
||||
sizeScale: number = 1.0,
|
||||
): Layer[] {
|
||||
return useMemo(() => {
|
||||
if (analysisMap.size === 0) return [];
|
||||
|
||||
const analyzedShips: AnalyzedShip[] = ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
|
||||
|
||||
const riskData = analyzedShips
|
||||
.filter(({ dto }) => {
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99;
|
||||
const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99;
|
||||
return pa - pb;
|
||||
})
|
||||
.slice(0, 100);
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// 위험도 원형 마커
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnalyzedShip>({
|
||||
id: 'risk-markers',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// 위험도 라벨 (선박명 + 위험도 등급)
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'risk-labels',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level;
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
|
||||
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
|
||||
if (activeFilter === 'darkVessel') {
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
|
||||
if (darkData.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnalyzedShip>({
|
||||
id: 'dark-vessel-markers',
|
||||
data: darkData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: 12 * sizeScale,
|
||||
getFillColor: [168, 85, 247, 40],
|
||||
getLineColor: [168, 85, 247, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// 다크베셀 gap 라벨
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'dark-vessel-labels',
|
||||
data: darkData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GPS 스푸핑 라벨
|
||||
const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
||||
if (spoofData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'spoof-labels',
|
||||
data: spoofData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'start',
|
||||
getPixelOffset: [12, -8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [analysisMap, ships, activeFilter, sizeScale]);
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import type { Ship } from '../types';
|
||||
import { classifyFishingZone } from '../utils/fishingAnalysis';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
|
||||
interface KoreaFilters {
|
||||
illegalFishing: boolean;
|
||||
@ -41,8 +43,10 @@ export function useKoreaFilters(
|
||||
koreaShips: Ship[],
|
||||
visibleShips: Ship[],
|
||||
currentTime: number,
|
||||
analysisMap?: Map<string, VesselAnalysisDto>,
|
||||
cnFishingOn = false,
|
||||
): UseKoreaFiltersResult {
|
||||
const [filters, setFilters] = useState<KoreaFilters>({
|
||||
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
|
||||
illegalFishing: false,
|
||||
illegalTransship: false,
|
||||
darkVessel: false,
|
||||
@ -67,7 +71,8 @@ export function useKoreaFilters(
|
||||
filters.darkVessel ||
|
||||
filters.cableWatch ||
|
||||
filters.dokdoWatch ||
|
||||
filters.ferryWatch;
|
||||
filters.ferryWatch ||
|
||||
cnFishingOn;
|
||||
|
||||
// 불법환적 의심 선박 탐지
|
||||
const transshipSuspects = useMemo(() => {
|
||||
@ -190,8 +195,17 @@ export function useKoreaFilters(
|
||||
}
|
||||
}
|
||||
|
||||
// Python 분류 결과 합집합: is_dark=true인 mmsi 추가
|
||||
if (analysisMap) {
|
||||
for (const [mmsi, dto] of analysisMap.entries()) {
|
||||
if (dto.algorithms.darkVessel.isDark) {
|
||||
result.add(mmsi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.darkVessel, currentTime]);
|
||||
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]);
|
||||
|
||||
// 해저케이블 감시
|
||||
const cableWatchSet = useMemo(() => {
|
||||
@ -297,15 +311,32 @@ export function useKoreaFilters(
|
||||
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.illegalFishing) {
|
||||
// 특정어업수역 Ⅰ~Ⅳ 내 비한국 어선만 불법어선으로 판별
|
||||
if (mtCat === 'fishing' && s.flag !== 'KR') {
|
||||
const zoneInfo = classifyFishingZone(s.lat, s.lng);
|
||||
if (zoneInfo.zone !== 'OUTSIDE') return true;
|
||||
}
|
||||
// Python 분석: 영해/접속수역 침범
|
||||
const analysis = analysisMap?.get(s.mmsi);
|
||||
if (analysis) {
|
||||
const zone = analysis.algorithms.location.zone;
|
||||
if (zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE') 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;
|
||||
if (cnFishingOn) {
|
||||
const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing';
|
||||
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
|
||||
if (isCnFishing || isGearPattern) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
||||
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
||||
68
frontend/src/hooks/useLocalStorage.ts
Normal file
68
frontend/src/hooks/useLocalStorage.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const PREFIX = 'kcg.';
|
||||
|
||||
/**
|
||||
* localStorage 연동 useState — JSON 직렬화/역직렬화 자동 처리.
|
||||
* 새 키가 추가된 Record 타입은 defaults와 자동 머지.
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] {
|
||||
const storageKey = PREFIX + key;
|
||||
|
||||
const [value, setValueRaw] = useState<T>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw === null) return defaults;
|
||||
const parsed = JSON.parse(raw) as T;
|
||||
// Record 타입이면 defaults에 있는 키가 저장값에 없을 때 머지
|
||||
if (defaults !== null && typeof defaults === 'object' && !Array.isArray(defaults)) {
|
||||
return { ...defaults, ...parsed };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = useCallback((updater: T | ((prev: T) => T)) => {
|
||||
setValueRaw(prev => {
|
||||
const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch { /* quota exceeded — 무시 */ }
|
||||
return next;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set<string>용 localStorage 연동 — 내부적으로 Array로 직렬화.
|
||||
*/
|
||||
export function useLocalStorageSet(key: string, defaults: Set<string>): [Set<string>, (v: Set<string> | ((prev: Set<string>) => Set<string>)) => void] {
|
||||
const storageKey = PREFIX + key;
|
||||
|
||||
const [value, setValueRaw] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw === null) return defaults;
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? new Set(arr) : defaults;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = useCallback((updater: Set<string> | ((prev: Set<string>) => Set<string>)) => {
|
||||
setValueRaw(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
|
||||
} catch { /* quota exceeded */ }
|
||||
return next;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
1086
frontend/src/hooks/useStaticDeckLayers.ts
Normal file
1086
frontend/src/hooks/useStaticDeckLayers.ts
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
114
frontend/src/hooks/useVesselAnalysis.ts
Normal file
114
frontend/src/hooks/useVesselAnalysis.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import type { VesselAnalysisDto, RiskLevel } from '../types';
|
||||
import { fetchVesselAnalysis } from '../services/vesselAnalysis';
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
|
||||
const STALE_MS = 30 * 60_000; // 30분
|
||||
|
||||
export interface AnalysisStats {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
dark: number;
|
||||
spoofing: number;
|
||||
clusterCount: number;
|
||||
}
|
||||
|
||||
export interface UseVesselAnalysisResult {
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
stats: AnalysisStats;
|
||||
clusters: Map<number, string[]>;
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
const EMPTY_STATS: AnalysisStats = {
|
||||
total: 0, critical: 0, high: 0, medium: 0, low: 0,
|
||||
dark: 0, spoofing: 0, clusterCount: 0,
|
||||
};
|
||||
|
||||
export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult {
|
||||
const mapRef = useRef<Map<string, VesselAnalysisDto>>(new Map());
|
||||
const [version, setVersion] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(0);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const items = await fetchVesselAnalysis();
|
||||
const now = Date.now();
|
||||
const map = mapRef.current;
|
||||
|
||||
// stale 제거
|
||||
for (const [mmsi, dto] of map) {
|
||||
const ts = new Date(dto.timestamp).getTime();
|
||||
if (now - ts > STALE_MS) map.delete(mmsi);
|
||||
}
|
||||
|
||||
// 새 결과 merge
|
||||
for (const item of items) {
|
||||
map.set(item.mmsi, item);
|
||||
}
|
||||
|
||||
setLastUpdated(now);
|
||||
setVersion(v => v + 1);
|
||||
} catch {
|
||||
// 에러 시 기존 데이터 유지 (graceful degradation)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
doFetch();
|
||||
const t = setInterval(doFetch, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [doFetch]);
|
||||
|
||||
const analysisMap = mapRef.current;
|
||||
|
||||
const stats = useMemo((): AnalysisStats => {
|
||||
if (analysisMap.size === 0) return EMPTY_STATS;
|
||||
let critical = 0, high = 0, medium = 0, low = 0, dark = 0, spoofing = 0;
|
||||
const clusterIds = new Set<number>();
|
||||
|
||||
for (const dto of analysisMap.values()) {
|
||||
const level: RiskLevel = dto.algorithms.riskScore.level;
|
||||
if (level === 'CRITICAL') critical++;
|
||||
else if (level === 'HIGH') high++;
|
||||
else if (level === 'MEDIUM') medium++;
|
||||
else low++;
|
||||
|
||||
if (dto.algorithms.darkVessel.isDark) dark++;
|
||||
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofing++;
|
||||
if (dto.algorithms.cluster.clusterId >= 0) {
|
||||
clusterIds.add(dto.algorithms.cluster.clusterId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: analysisMap.size, critical, high, medium, low,
|
||||
dark, spoofing, clusterCount: clusterIds.size,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version]);
|
||||
|
||||
const clusters = useMemo((): Map<number, string[]> => {
|
||||
const result = new Map<number, string[]>();
|
||||
for (const [mmsi, dto] of analysisMap) {
|
||||
const cid = dto.algorithms.cluster.clusterId;
|
||||
if (cid < 0) continue;
|
||||
const arr = result.get(cid);
|
||||
if (arr) arr.push(mmsi);
|
||||
else result.set(cid, [mmsi]);
|
||||
}
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version]);
|
||||
|
||||
return { analysisMap, stats, clusters, isLoading, lastUpdated };
|
||||
}
|
||||
30
frontend/src/services/vesselAnalysis.ts
Normal file
30
frontend/src/services/vesselAnalysis.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { VesselAnalysisDto } from '../types';
|
||||
|
||||
const API_BASE = '/api/kcg';
|
||||
|
||||
export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis`, {
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: { count: number; items: VesselAnalysisDto[] } = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export interface FleetCompany {
|
||||
id: number;
|
||||
nameCn: string;
|
||||
nameEn: string;
|
||||
}
|
||||
|
||||
// 캐시 (세션 중 1회 로드)
|
||||
let companyCache: Map<number, FleetCompany> | null = null;
|
||||
|
||||
export async function fetchFleetCompanies(): Promise<Map<number, FleetCompany>> {
|
||||
if (companyCache) return companyCache;
|
||||
const res = await fetch(`${API_BASE}/fleet-companies`);
|
||||
if (!res.ok) return new Map();
|
||||
const items: FleetCompany[] = await res.json();
|
||||
companyCache = new Map(items.map(c => [c.id, c]));
|
||||
return companyCache;
|
||||
}
|
||||
39
frontend/src/services/vesselTrack.ts
Normal file
39
frontend/src/services/vesselTrack.ts
Normal file
@ -0,0 +1,39 @@
|
||||
const SIGNAL_BATCH_BASE = '/signal-batch';
|
||||
|
||||
interface TrackResponse {
|
||||
vesselId: string;
|
||||
geometry: [number, number][];
|
||||
speeds: number[];
|
||||
timestamps: string[];
|
||||
pointCount: number;
|
||||
totalDistance: number;
|
||||
shipName: string;
|
||||
}
|
||||
|
||||
// mmsi별 캐시 (TTL 5분)
|
||||
const trackCache = new Map<string, { time: number; coords: [number, number][] }>();
|
||||
const CACHE_TTL = 5 * 60_000;
|
||||
|
||||
export async function fetchVesselTrack(mmsi: string, hours: number = 6): Promise<[number, number][]> {
|
||||
const cached = trackCache.get(mmsi);
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) return cached.coords;
|
||||
|
||||
const endTime = new Date().toISOString();
|
||||
const startTime = new Date(Date.now() - hours * 3600_000).toISOString();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v2/tracks/vessels`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ startTime, endTime, vessels: [mmsi] }),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: TrackResponse[] = await res.json();
|
||||
if (!data.length || !data[0].geometry?.length) return [];
|
||||
const coords = data[0].geometry;
|
||||
trackCache.set(mmsi, { time: Date.now(), coords });
|
||||
return coords;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -146,6 +146,7 @@ export interface LayerVisibility {
|
||||
militaryOnly: boolean;
|
||||
overseasUS: boolean;
|
||||
overseasIsrael: boolean;
|
||||
[key: string]: boolean;
|
||||
overseasIran: boolean;
|
||||
overseasUAE: boolean;
|
||||
overseasSaudi: boolean;
|
||||
@ -154,65 +155,34 @@ export interface LayerVisibility {
|
||||
overseasKuwait: boolean;
|
||||
overseasIraq: boolean;
|
||||
overseasBahrain: boolean;
|
||||
// Dynamic keys for energy/hazard sub-layers
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export type AppMode = 'replay' | 'live';
|
||||
|
||||
// ── 중국어선 분석 결과 (Python 분류기 → REST API → Frontend) ──
|
||||
// Vessel analysis (Python prediction 결과)
|
||||
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP' | 'UNKNOWN';
|
||||
export type RiskLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
export type ActivityState = 'STATIONARY' | 'FISHING' | 'SAILING' | 'UNKNOWN';
|
||||
export type FleetRole = 'LEADER' | 'MEMBER' | 'NOISE';
|
||||
|
||||
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP';
|
||||
export type RiskLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
|
||||
export type ActivityState = 'FISHING' | 'SAILING' | 'STATIONARY' | 'AIS_LOSS';
|
||||
export type ZoneType = 'TERRITORIAL' | 'CONTIGUOUS' | 'EEZ' | 'BEYOND';
|
||||
export type FleetRole = 'MOTHER' | 'SUB' | 'TRANSPORT' | 'INDEPENDENT';
|
||||
|
||||
export interface VesselClassification {
|
||||
vesselType: VesselType;
|
||||
confidence: number; // 0~1
|
||||
fishingPct: number; // 조업 비율 %
|
||||
clusterId: number; // BIRCH 군집 ID (-1=노이즈)
|
||||
season: string; // SPRING/SUMMER/FALL/WINTER
|
||||
}
|
||||
|
||||
export interface VesselAlgorithms {
|
||||
location: { zone: ZoneType; distToBaselineNm: number };
|
||||
activity: { state: ActivityState; ucafScore: number; ucftScore: number };
|
||||
darkVessel: { isDark: boolean; gapDurationMin: number };
|
||||
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
|
||||
cluster: { clusterId: number; clusterSize: number; centroid?: [number, number] };
|
||||
fleetRole: { isLeader: boolean; role: FleetRole };
|
||||
riskScore: { score: number; level: RiskLevel };
|
||||
}
|
||||
|
||||
export interface VesselAnalysisResult {
|
||||
export interface VesselAnalysisDto {
|
||||
mmsi: string;
|
||||
timestamp: string; // ISO 분석 시점
|
||||
classification: VesselClassification;
|
||||
algorithms: VesselAlgorithms;
|
||||
timestamp: string;
|
||||
classification: {
|
||||
vesselType: VesselType;
|
||||
confidence: number;
|
||||
fishingPct: number;
|
||||
clusterId: number;
|
||||
season: string;
|
||||
};
|
||||
algorithms: {
|
||||
location: { zone: string; distToBaselineNm: number };
|
||||
activity: { state: ActivityState; ucafScore: number; ucftScore: number };
|
||||
darkVessel: { isDark: boolean; gapDurationMin: number };
|
||||
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
|
||||
cluster: { clusterId: number; clusterSize: number };
|
||||
fleetRole: { isLeader: boolean; role: FleetRole };
|
||||
riskScore: { score: number; level: RiskLevel };
|
||||
};
|
||||
features: Record<string, number>;
|
||||
}
|
||||
|
||||
// 허가어선 정보 (signal-batch /api/v2/vessels/chnprmship)
|
||||
export interface ChnPrmShipInfo {
|
||||
mmsi: string;
|
||||
imo: number;
|
||||
name: string;
|
||||
callsign: string;
|
||||
vesselType: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
sog: number;
|
||||
cog: number;
|
||||
heading: number;
|
||||
length: number;
|
||||
width: number;
|
||||
draught: number;
|
||||
destination: string;
|
||||
status: string;
|
||||
signalKindCode: string;
|
||||
messageTimestamp: string;
|
||||
shipImagePath?: string | null;
|
||||
shipImageCount?: number;
|
||||
}
|
||||
|
||||
@ -2,6 +2,11 @@
|
||||
// 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류
|
||||
|
||||
import type { Ship } from '../types';
|
||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||
import { point } from '@turf/helpers';
|
||||
import type { Feature, MultiPolygon } from 'geojson';
|
||||
|
||||
import fishingZonesWgs84 from '../data/zones/fishing-zones-wgs84.json';
|
||||
|
||||
/**
|
||||
* 중국 허가 업종 코드 (허가번호 접두사)
|
||||
@ -42,16 +47,45 @@ const GEAR_META: Record<FishingGearType, {
|
||||
|
||||
export { GEAR_META as GEAR_LABELS };
|
||||
|
||||
/**
|
||||
* 특정어업수역 정의 (한중어업협정)
|
||||
*/
|
||||
const _FISHING_ZONES = {
|
||||
I: { name: '수역Ⅰ(동해)', lngMin: 128.86, lngMax: 131.67, latMin: 35.65, latMax: 38.25, allowed: ['PS', 'FC'] },
|
||||
II: { name: '수역Ⅱ(남해)', lngMin: 126.00, lngMax: 128.89, latMin: 32.18, latMax: 34.34, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
|
||||
III:{ name: '수역Ⅲ(서남해)', lngMin: 124.01, lngMax: 126.08, latMin: 32.18, latMax: 35.00, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
|
||||
IV: { name: '수역Ⅳ(서해)', lngMin: 124.13, lngMax: 125.85, latMin: 35.00, latMax: 37.00, allowed: ['GN', 'PS', 'FC'] },
|
||||
export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE';
|
||||
|
||||
export interface FishingZoneInfo {
|
||||
zone: FishingZoneId;
|
||||
name: string;
|
||||
allowed: string[];
|
||||
}
|
||||
|
||||
/** 수역별 허가 업종 */
|
||||
const ZONE_ALLOWED: Record<string, string[]> = {
|
||||
ZONE_I: ['PS', 'FC'],
|
||||
ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'],
|
||||
ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'],
|
||||
ZONE_IV: ['GN', 'PS', 'FC'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정어업수역 Ⅰ~Ⅳ 폴리곤 (사전 변환된 WGS84 GeoJSON)
|
||||
*/
|
||||
export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({
|
||||
id: f.properties.id as FishingZoneId,
|
||||
name: f.properties.name,
|
||||
allowed: ZONE_ALLOWED[f.properties.id] ?? [],
|
||||
geojson: f as unknown as Feature<MultiPolygon>,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 특정어업수역 폴리곤 기반 수역 분류
|
||||
*/
|
||||
export function classifyFishingZone(lat: number, lng: number): FishingZoneInfo {
|
||||
const pt = point([lng, lat]);
|
||||
for (const z of ZONE_POLYGONS) {
|
||||
if (booleanPointInPolygon(pt, z.geojson)) {
|
||||
return { zone: z.id, name: z.name, allowed: z.allowed };
|
||||
}
|
||||
}
|
||||
return { zone: 'OUTSIDE', name: '수역 외', allowed: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 업종별 허가 기간 (월/일)
|
||||
*/
|
||||
|
||||
@ -20,6 +20,22 @@ export interface FleetConnection {
|
||||
fleetTypeKo: string;
|
||||
}
|
||||
|
||||
// ── 사전 그룹핑 인터페이스 ──
|
||||
|
||||
export interface FleetGroupMember {
|
||||
ship: Ship;
|
||||
role: FleetRole;
|
||||
roleKo: string;
|
||||
}
|
||||
|
||||
export interface FleetGroup {
|
||||
groupId: number;
|
||||
fleetType: 'trawl_pair' | 'purse_seine_fleet' | 'transship' | 'cluster';
|
||||
fleetTypeKo: string;
|
||||
members: FleetGroupMember[];
|
||||
center: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/** 두 지점 사이 거리(NM) */
|
||||
function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 3440.065; // 지구 반경 (해리)
|
||||
@ -29,8 +45,167 @@ function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/** 선박이 중국 어선 후보인지 판별 */
|
||||
function isCnFishingCandidate(ship: Ship): boolean {
|
||||
if (ship.flag !== 'CN') return false;
|
||||
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
return cat === 'fishing' || cat === 'unspecified';
|
||||
}
|
||||
|
||||
/** 그룹 내 역할 결정 */
|
||||
function assignRoles(members: Ship[]): FleetGroupMember[] {
|
||||
if (members.length === 0) return [];
|
||||
|
||||
// 운반선 후보: 이름에 냉동운반선 관련 한자 포함 또는 cargo 카테고리
|
||||
const isCarrierCandidate = (s: Ship): boolean => {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
return cat === 'cargo' || s.name.includes('运') || s.name.includes('冷');
|
||||
};
|
||||
|
||||
// 최대 속도 선박 → mothership
|
||||
const maxSpeed = Math.max(...members.map(s => s.speed));
|
||||
const motherIdx = members.findIndex(s => s.speed === maxSpeed);
|
||||
|
||||
return members.map((ship, idx) => {
|
||||
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
|
||||
if (isCarrierCandidate(ship) && ship.speed <= 2) {
|
||||
return { ship, role: 'carrier' as FleetRole, roleKo: '운반선 (FC)' };
|
||||
}
|
||||
if (ship.speed < 1 && (cat === 'fishing' || cat === 'unspecified')) {
|
||||
return { ship, role: 'lighting' as FleetRole, roleKo: '조명선' };
|
||||
}
|
||||
if (idx === motherIdx && members.length > 1) {
|
||||
return { ship, role: 'mothership' as FleetRole, roleKo: '모선' };
|
||||
}
|
||||
return { ship, role: 'subsidiary' as FleetRole, roleKo: '선단 멤버' };
|
||||
});
|
||||
}
|
||||
|
||||
/** 그룹 유형 판별 */
|
||||
function classifyGroup(members: Ship[]): { fleetType: FleetGroup['fleetType']; fleetTypeKo: string } {
|
||||
if (members.length === 2) {
|
||||
const [a, b] = members;
|
||||
const speedDiff = Math.abs(a.speed - b.speed);
|
||||
let headingDiff = Math.abs(a.heading - b.heading);
|
||||
if (headingDiff > 180) headingDiff = 360 - headingDiff;
|
||||
if (
|
||||
speedDiff < 1 && headingDiff < 20 &&
|
||||
a.speed >= 2 && a.speed <= 5 &&
|
||||
b.speed >= 2 && b.speed <= 5
|
||||
) {
|
||||
return { fleetType: 'trawl_pair', fleetTypeKo: '2척식 저인망 (본선·부속선)' };
|
||||
}
|
||||
}
|
||||
|
||||
const hasCarrier = members.some(s => {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
return (cat === 'cargo' || s.name.includes('运') || s.name.includes('冷')) && s.speed <= 2;
|
||||
});
|
||||
if (hasCarrier) {
|
||||
return { fleetType: 'transship', fleetTypeKo: '환적 의심 (운반선 접근)' };
|
||||
}
|
||||
|
||||
const hasLighting = members.some(s => s.speed < 1);
|
||||
if (members.length >= 3 || hasLighting) {
|
||||
return { fleetType: 'purse_seine_fleet', fleetTypeKo: '위망 선단 (모선·운반·조명)' };
|
||||
}
|
||||
|
||||
return { fleetType: 'cluster', fleetTypeKo: '인근 어선 클러스터' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 중국어선 주변의 선단 구성을 탐지
|
||||
* 전체 중국어선을 사전 클러스터링하여 선단 그룹 생성.
|
||||
* 어느 멤버를 눌러도 같은 그룹을 반환할 수 있도록 mmsi→groupId 맵도 함께 반환.
|
||||
*
|
||||
* 알고리즘: 3NM 이내 BFS flood fill (O(N²), N>1000 시 0.3도 박스 사전필터)
|
||||
*/
|
||||
export function buildFleetGroups(ships: Ship[]): {
|
||||
groups: FleetGroup[];
|
||||
memberMap: Map<string, number>;
|
||||
} {
|
||||
// 중국 어선 후보만 추출
|
||||
const candidates = ships.filter(isCnFishingCandidate);
|
||||
|
||||
// N > 1000이면 0.3도 박스로 사전필터링하여 인접 목록 구성
|
||||
const USE_BBOX_FILTER = candidates.length > 1000;
|
||||
const CLUSTER_NM = 3;
|
||||
const BBOX_DEG = 0.3; // ~18NM
|
||||
|
||||
// 미할당 인덱스 집합
|
||||
const unassigned = new Set<number>(candidates.map((_, i) => i));
|
||||
|
||||
const groups: FleetGroup[] = [];
|
||||
const memberMap = new Map<string, number>();
|
||||
|
||||
let groupId = 0;
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (!unassigned.has(i)) continue;
|
||||
|
||||
// BFS
|
||||
const cluster: number[] = [];
|
||||
const queue: number[] = [i];
|
||||
unassigned.delete(i);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
cluster.push(cur);
|
||||
const curShip = candidates[cur];
|
||||
|
||||
// 탐색 후보: bbox 필터 또는 전체
|
||||
const searchPool = USE_BBOX_FILTER
|
||||
? candidates.reduce<number[]>((acc, s, idx) => {
|
||||
if (
|
||||
unassigned.has(idx) &&
|
||||
Math.abs(s.lat - curShip.lat) < BBOX_DEG &&
|
||||
Math.abs(s.lng - curShip.lng) < BBOX_DEG
|
||||
) {
|
||||
acc.push(idx);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
: Array.from(unassigned);
|
||||
|
||||
for (const j of searchPool) {
|
||||
if (!unassigned.has(j)) continue;
|
||||
const neighbor = candidates[j];
|
||||
if (distNm(curShip.lat, curShip.lng, neighbor.lat, neighbor.lng) <= CLUSTER_NM) {
|
||||
unassigned.delete(j);
|
||||
queue.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2척 미만은 그룹 해제
|
||||
if (cluster.length < 2) continue;
|
||||
|
||||
const memberShips = cluster.map(idx => candidates[idx]);
|
||||
const { fleetType, fleetTypeKo } = classifyGroup(memberShips);
|
||||
const memberRoles = assignRoles(memberShips);
|
||||
|
||||
// 중심점 계산
|
||||
const centerLat = memberShips.reduce((sum, s) => sum + s.lat, 0) / memberShips.length;
|
||||
const centerLng = memberShips.reduce((sum, s) => sum + s.lng, 0) / memberShips.length;
|
||||
|
||||
const group: FleetGroup = {
|
||||
groupId,
|
||||
fleetType,
|
||||
fleetTypeKo,
|
||||
members: memberRoles,
|
||||
center: { lat: centerLat, lng: centerLng },
|
||||
};
|
||||
|
||||
groups.push(group);
|
||||
memberShips.forEach(s => memberMap.set(s.mmsi, groupId));
|
||||
groupId++;
|
||||
}
|
||||
|
||||
return { groups, memberMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 중국어선 주변의 선단 구성을 탐지 (기존 1척 기준 탐지 — fallback용 유지)
|
||||
*
|
||||
* 보고서 기준:
|
||||
* - PT 2척식 저인망: 본선+부속선 3NM 이내, 유사 속도(2~5kn), 유사 방향
|
||||
|
||||
3
frontend/src/utils/svgToDataUri.ts
Normal file
3
frontend/src/utils/svgToDataUri.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function svgToDataUri(svg: string): string {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@ -1,152 +1,177 @@
|
||||
import math
|
||||
"""선단(Fleet) 패턴 탐지 — 공간+행동 기반.
|
||||
|
||||
단순 공간 근접이 아닌, 협조 운항 패턴(유사 속도/방향/역할)으로 선단을 판별.
|
||||
- PT 저인망: 2척, 3NM 이내, 유사 속도(2~5kn) + 유사 방향(20° 이내)
|
||||
- PS 선망: 3~5척, 2NM 이내, 모선(고속)+조명선(정지)+운반선(저속 대형)
|
||||
- FC 환적: 2척, 0.5NM 이내, 양쪽 저속(2kn 이하)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from algorithms.location import haversine_nm, dist_to_baseline, EARTH_RADIUS_NM
|
||||
from algorithms.location import haversine_nm, dist_to_baseline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def detect_group_clusters(
|
||||
vessel_snapshots: list[dict],
|
||||
spatial_eps_nm: float = 10.0,
|
||||
time_eps_hours: float = 2.0,
|
||||
min_vessels: int = 3,
|
||||
def _heading_diff(h1: float, h2: float) -> float:
|
||||
"""두 방향 사이 최소 각도차 (0~180)."""
|
||||
d = abs(h1 - h2) % 360
|
||||
return d if d <= 180 else 360 - d
|
||||
|
||||
|
||||
def detect_fleet_patterns(
|
||||
vessel_dfs: dict[str, pd.DataFrame],
|
||||
) -> dict[int, list[dict]]:
|
||||
"""DBSCAN 시공간 클러스터링으로 집단 탐지."""
|
||||
if len(vessel_snapshots) < min_vessels:
|
||||
return {}
|
||||
"""행동 패턴 기반 선단 탐지.
|
||||
|
||||
try:
|
||||
from sklearn.cluster import DBSCAN
|
||||
except ImportError:
|
||||
logger.warning('sklearn not available for DBSCAN clustering')
|
||||
return {}
|
||||
|
||||
lat_rad = [math.radians(v['lat']) * EARTH_RADIUS_NM for v in vessel_snapshots]
|
||||
lon_rad = [math.radians(v['lon']) * EARTH_RADIUS_NM for v in vessel_snapshots]
|
||||
|
||||
# 시간을 NM 단위로 정규화
|
||||
timestamps = [pd.Timestamp(v['timestamp']).timestamp() for v in vessel_snapshots]
|
||||
t_min = min(timestamps)
|
||||
time_nm = [(t - t_min) / 3600 * 10 / time_eps_hours for t in timestamps]
|
||||
|
||||
X = np.array(list(zip(lat_rad, lon_rad, time_nm)))
|
||||
|
||||
db = DBSCAN(eps=spatial_eps_nm, min_samples=min_vessels, metric='euclidean').fit(X)
|
||||
|
||||
clusters: dict[int, list[dict]] = {}
|
||||
for idx, label in enumerate(db.labels_):
|
||||
if label == -1:
|
||||
Returns: {fleet_id: [{mmsi, lat, lon, sog, cog, role, pattern}, ...]}
|
||||
"""
|
||||
# 각 선박의 최신 스냅샷 추출
|
||||
snapshots: list[dict] = []
|
||||
for mmsi, df in vessel_dfs.items():
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
clusters.setdefault(int(label), []).append(vessel_snapshots[idx])
|
||||
last = df.iloc[-1]
|
||||
snapshots.append({
|
||||
'mmsi': mmsi,
|
||||
'lat': float(last['lat']),
|
||||
'lon': float(last['lon']),
|
||||
'sog': float(last.get('sog', 0)),
|
||||
'cog': float(last.get('cog', 0)),
|
||||
})
|
||||
|
||||
return clusters
|
||||
|
||||
|
||||
def identify_lead_vessel(cluster_vessels: list[dict]) -> dict:
|
||||
"""5기준 스코어링으로 대표선 특정."""
|
||||
if not cluster_vessels:
|
||||
if len(snapshots) < 2:
|
||||
return {}
|
||||
|
||||
scores: dict[str, float] = {}
|
||||
matched: set[str] = set()
|
||||
fleets: dict[int, list[dict]] = {}
|
||||
fleet_id = 0
|
||||
|
||||
timestamps = [pd.Timestamp(v.get('timestamp', 0)).timestamp() for v in cluster_vessels]
|
||||
min_ts = min(timestamps) if timestamps else 0
|
||||
# 1차: PT 저인망 쌍 탐지 (2척, 3NM, 유사 속도/방향)
|
||||
for i in range(len(snapshots)):
|
||||
if snapshots[i]['mmsi'] in matched:
|
||||
continue
|
||||
a = snapshots[i]
|
||||
for j in range(i + 1, len(snapshots)):
|
||||
if snapshots[j]['mmsi'] in matched:
|
||||
continue
|
||||
b = snapshots[j]
|
||||
dist = haversine_nm(a['lat'], a['lon'], b['lat'], b['lon'])
|
||||
if dist > 3.0:
|
||||
continue
|
||||
# 둘 다 조업 속도 (2~5kn)
|
||||
if not (2.0 <= a['sog'] <= 5.0 and 2.0 <= b['sog'] <= 5.0):
|
||||
continue
|
||||
# 유사 속도 (차이 1kn 미만)
|
||||
if abs(a['sog'] - b['sog']) >= 1.0:
|
||||
continue
|
||||
# 유사 방향 (20° 미만)
|
||||
if _heading_diff(a['cog'], b['cog']) >= 20.0:
|
||||
continue
|
||||
|
||||
lats = [v['lat'] for v in cluster_vessels]
|
||||
lons = [v['lon'] for v in cluster_vessels]
|
||||
centroid_lat = float(np.mean(lats))
|
||||
centroid_lon = float(np.mean(lons))
|
||||
fleets[fleet_id] = [
|
||||
{**a, 'role': 'LEADER', 'pattern': 'TRAWL_PAIR'},
|
||||
{**b, 'role': 'MEMBER', 'pattern': 'TRAWL_PAIR'},
|
||||
]
|
||||
matched.add(a['mmsi'])
|
||||
matched.add(b['mmsi'])
|
||||
fleet_id += 1
|
||||
break
|
||||
|
||||
for i, v in enumerate(cluster_vessels):
|
||||
mmsi = v['mmsi']
|
||||
s = 0.0
|
||||
# 2차: FC 환적 쌍 탐지 (2척, 0.5NM, 양쪽 저속)
|
||||
for i in range(len(snapshots)):
|
||||
if snapshots[i]['mmsi'] in matched:
|
||||
continue
|
||||
a = snapshots[i]
|
||||
for j in range(i + 1, len(snapshots)):
|
||||
if snapshots[j]['mmsi'] in matched:
|
||||
continue
|
||||
b = snapshots[j]
|
||||
dist = haversine_nm(a['lat'], a['lon'], b['lat'], b['lon'])
|
||||
if dist > 0.5:
|
||||
continue
|
||||
if a['sog'] > 2.0 or b['sog'] > 2.0:
|
||||
continue
|
||||
|
||||
# 기준 1: 최초 시각 (30점)
|
||||
ts_rank = timestamps[i] - min_ts
|
||||
s += 30.0 * (1.0 - min(ts_rank, 7200) / 7200)
|
||||
fleets[fleet_id] = [
|
||||
{**a, 'role': 'LEADER', 'pattern': 'TRANSSHIP'},
|
||||
{**b, 'role': 'MEMBER', 'pattern': 'TRANSSHIP'},
|
||||
]
|
||||
matched.add(a['mmsi'])
|
||||
matched.add(b['mmsi'])
|
||||
fleet_id += 1
|
||||
break
|
||||
|
||||
# 기준 2: 총톤수 (25점) — 외부 DB 연동 전까지 균등 배점
|
||||
s += 12.5
|
||||
# 3차: PS 선망 선단 탐지 (3~10척, 2NM 이내 클러스터)
|
||||
unmatched = [s for s in snapshots if s['mmsi'] not in matched]
|
||||
for anchor in unmatched:
|
||||
if anchor['mmsi'] in matched:
|
||||
continue
|
||||
nearby = []
|
||||
for other in unmatched:
|
||||
if other['mmsi'] == anchor['mmsi'] or other['mmsi'] in matched:
|
||||
continue
|
||||
dist = haversine_nm(anchor['lat'], anchor['lon'], other['lat'], other['lon'])
|
||||
if dist <= 2.0:
|
||||
nearby.append(other)
|
||||
|
||||
# 기준 3: 클러스터 중심 근접성 (20점)
|
||||
dist_center = haversine_nm(v['lat'], v['lon'], centroid_lat, centroid_lon)
|
||||
s += 20.0 * (1.0 - min(dist_center, 10) / 10)
|
||||
if len(nearby) < 2: # 본인 포함 3척 이상
|
||||
continue
|
||||
|
||||
# 기준 4: 기선 최근접 (15점)
|
||||
dist_base = dist_to_baseline(v['lat'], v['lon'])
|
||||
s += 15.0 * (1.0 - min(dist_base, 12) / 12)
|
||||
# 역할 분류: 고속(모선), 정지(조명선), 나머지(멤버)
|
||||
members = [{**anchor, 'role': 'LEADER', 'pattern': 'PURSE_SEINE'}]
|
||||
matched.add(anchor['mmsi'])
|
||||
for n in nearby[:9]: # 최대 10척
|
||||
if n['sog'] < 0.5:
|
||||
role = 'LIGHTING'
|
||||
else:
|
||||
role = 'MEMBER'
|
||||
members.append({**n, 'role': role, 'pattern': 'PURSE_SEINE'})
|
||||
matched.add(n['mmsi'])
|
||||
|
||||
# 기준 5: AIS 소실 이력 (10점) — 이력 없으면 만점
|
||||
s += 10.0
|
||||
fleets[fleet_id] = members
|
||||
fleet_id += 1
|
||||
|
||||
scores[mmsi] = round(s, 2)
|
||||
|
||||
lead_mmsi = max(scores, key=lambda k: scores[k])
|
||||
score_vals = sorted(scores.values(), reverse=True)
|
||||
|
||||
if len(score_vals) > 1 and score_vals[0] - score_vals[1] > 15:
|
||||
confidence = 'HIGH'
|
||||
elif len(score_vals) > 1 and score_vals[0] - score_vals[1] > 8:
|
||||
confidence = 'MED'
|
||||
else:
|
||||
confidence = 'LOW'
|
||||
|
||||
return {
|
||||
'lead_mmsi': lead_mmsi,
|
||||
'lead_score': scores[lead_mmsi],
|
||||
'all_scores': scores,
|
||||
'confidence': confidence,
|
||||
}
|
||||
logger.info('fleet detection: %d fleets found (%d vessels matched)',
|
||||
len(fleets), len(matched))
|
||||
return fleets
|
||||
|
||||
|
||||
def assign_fleet_roles(
|
||||
vessel_dfs: dict[str, pd.DataFrame],
|
||||
cluster_map: dict[str, int],
|
||||
) -> dict[str, dict]:
|
||||
"""선단 역할 할당: LEADER/MEMBER/NOISE."""
|
||||
"""선단 역할 할당 — 패턴 매칭 기반.
|
||||
|
||||
cluster_map은 파이프라인에서 전달되지만, 여기서는 vessel_dfs로 직접 패턴 탐지.
|
||||
"""
|
||||
fleets = detect_fleet_patterns(vessel_dfs)
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
|
||||
# 클러스터별 그룹핑
|
||||
clusters: dict[int, list[str]] = {}
|
||||
for mmsi, cid in cluster_map.items():
|
||||
clusters.setdefault(cid, []).append(mmsi)
|
||||
# 매칭된 선박 (fleet_id를 cluster_id로 사용)
|
||||
fleet_mmsis: set[str] = set()
|
||||
for fid, members in fleets.items():
|
||||
for m in members:
|
||||
fleet_mmsis.add(m['mmsi'])
|
||||
results[m['mmsi']] = {
|
||||
'cluster_id': fid,
|
||||
'cluster_size': len(members),
|
||||
'is_leader': m['role'] == 'LEADER',
|
||||
'fleet_role': m['role'],
|
||||
}
|
||||
|
||||
for cid, mmsi_list in clusters.items():
|
||||
if cid == -1:
|
||||
for mmsi in mmsi_list:
|
||||
results[mmsi] = {
|
||||
'cluster_size': 0,
|
||||
'is_leader': False,
|
||||
'fleet_role': 'NOISE',
|
||||
}
|
||||
continue
|
||||
|
||||
cluster_size = len(mmsi_list)
|
||||
|
||||
# 스냅샷 생성 (각 선박의 마지막 포인트)
|
||||
snapshots: list[dict] = []
|
||||
for mmsi in mmsi_list:
|
||||
df = vessel_dfs.get(mmsi)
|
||||
if df is not None and len(df) > 0:
|
||||
last = df.iloc[-1]
|
||||
snapshots.append({
|
||||
'mmsi': mmsi,
|
||||
'lat': last['lat'],
|
||||
'lon': last['lon'],
|
||||
'timestamp': last.get('timestamp', pd.Timestamp.now()),
|
||||
})
|
||||
|
||||
lead_info = identify_lead_vessel(snapshots) if len(snapshots) >= 2 else {}
|
||||
lead_mmsi = lead_info.get('lead_mmsi')
|
||||
|
||||
for mmsi in mmsi_list:
|
||||
# 매칭 안 된 선박 → NOISE (cluster_id = -1)
|
||||
for mmsi in vessel_dfs:
|
||||
if mmsi not in fleet_mmsis:
|
||||
results[mmsi] = {
|
||||
'cluster_size': cluster_size,
|
||||
'is_leader': mmsi == lead_mmsi,
|
||||
'fleet_role': 'LEADER' if mmsi == lead_mmsi else 'MEMBER',
|
||||
'cluster_id': -1,
|
||||
'cluster_size': 0,
|
||||
'is_leader': False,
|
||||
'fleet_role': 'NOISE',
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
@ -10,6 +10,7 @@ TERRITORIAL_SEA_NM = 12.0
|
||||
CONTIGUOUS_ZONE_NM = 24.0
|
||||
|
||||
_baseline_points: Optional[List[Tuple[float, float]]] = None
|
||||
_zone_polygons: Optional[list] = None
|
||||
|
||||
|
||||
def _load_baseline() -> List[Tuple[float, float]]:
|
||||
@ -46,10 +47,91 @@ def dist_to_baseline(vessel_lat: float, vessel_lon: float,
|
||||
return min_dist
|
||||
|
||||
|
||||
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
|
||||
"""선박 위치 수역 분류."""
|
||||
dist = dist_to_baseline(vessel_lat, vessel_lon)
|
||||
def _epsg3857_to_wgs84(x: float, y: float) -> Tuple[float, float]:
|
||||
"""EPSG:3857 (Web Mercator) → WGS84 변환."""
|
||||
lon = x / (math.pi * 6378137) * 180
|
||||
lat = math.atan(math.exp(y / 6378137)) * 360 / math.pi - 90
|
||||
return lat, lon
|
||||
|
||||
|
||||
def _load_zone_polygons() -> list:
|
||||
"""특정어업수역 Ⅰ~Ⅳ GeoJSON 로드 + EPSG:3857→WGS84 변환."""
|
||||
global _zone_polygons
|
||||
if _zone_polygons is not None:
|
||||
return _zone_polygons
|
||||
|
||||
zone_dir = Path(__file__).parent.parent / 'data' / 'zones'
|
||||
zones_meta = [
|
||||
('ZONE_I', '수역Ⅰ(동해)', ['PS', 'FC'], '특정어업수역Ⅰ.json'),
|
||||
('ZONE_II', '수역Ⅱ(남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅱ.json'),
|
||||
('ZONE_III', '수역Ⅲ(서남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅲ.json'),
|
||||
('ZONE_IV', '수역Ⅳ(서해)', ['GN', 'PS', 'FC'], '특정어업수역Ⅳ.json'),
|
||||
]
|
||||
result = []
|
||||
for zone_id, name, allowed, filename in zones_meta:
|
||||
filepath = zone_dir / filename
|
||||
if not filepath.exists():
|
||||
continue
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
multi_coords = data['features'][0]['geometry']['coordinates']
|
||||
wgs84_polys = []
|
||||
for poly in multi_coords:
|
||||
wgs84_rings = []
|
||||
for ring in poly:
|
||||
wgs84_rings.append([_epsg3857_to_wgs84(x, y) for x, y in ring])
|
||||
wgs84_polys.append(wgs84_rings)
|
||||
result.append({
|
||||
'id': zone_id, 'name': name, 'allowed': allowed,
|
||||
'polygons': wgs84_polys,
|
||||
})
|
||||
_zone_polygons = result
|
||||
return result
|
||||
|
||||
|
||||
def _point_in_polygon(lat: float, lon: float, ring: list) -> bool:
|
||||
"""Ray-casting point-in-polygon."""
|
||||
n = len(ring)
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi, xi = ring[i]
|
||||
yj, xj = ring[j]
|
||||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def _point_in_multipolygon(lat: float, lon: float, polygons: list) -> bool:
|
||||
"""MultiPolygon 내 포함 여부 (외곽 링 in + 내곽 링 hole 제외)."""
|
||||
for poly in polygons:
|
||||
outer = poly[0]
|
||||
if _point_in_polygon(lat, lon, outer):
|
||||
for hole in poly[1:]:
|
||||
if _point_in_polygon(lat, lon, hole):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
|
||||
"""선박 위치 수역 분류 — 특정어업수역 Ⅰ~Ⅳ 폴리곤 기반."""
|
||||
zones = _load_zone_polygons()
|
||||
|
||||
for z in zones:
|
||||
if _point_in_multipolygon(vessel_lat, vessel_lon, z['polygons']):
|
||||
dist = dist_to_baseline(vessel_lat, vessel_lon)
|
||||
return {
|
||||
'zone': z['id'],
|
||||
'zone_name': z['name'],
|
||||
'allowed_gears': z['allowed'],
|
||||
'dist_from_baseline_nm': round(dist, 2),
|
||||
'violation': False,
|
||||
'alert_level': 'WATCH',
|
||||
}
|
||||
|
||||
dist = dist_to_baseline(vessel_lat, vessel_lon)
|
||||
if dist <= TERRITORIAL_SEA_NM:
|
||||
return {
|
||||
'zone': 'TERRITORIAL_SEA',
|
||||
|
||||
@ -32,6 +32,10 @@ def compute_vessel_risk_score(
|
||||
score += 40
|
||||
elif zone == 'CONTIGUOUS_ZONE':
|
||||
score += 10
|
||||
elif zone.startswith('ZONE_'):
|
||||
# 특정어업수역 내 — 무허가면 가산
|
||||
if is_permitted is not None and not is_permitted:
|
||||
score += 25
|
||||
|
||||
# 2. 조업 행위 (최대 30점)
|
||||
segs = detect_fishing_segments(df_vessel)
|
||||
|
||||
160
prediction/algorithms/track_similarity.py
Normal file
160
prediction/algorithms/track_similarity.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""궤적 유사도 — DTW(Dynamic Time Warping) 기반."""
|
||||
import math
|
||||
|
||||
_MAX_RESAMPLE_POINTS = 50
|
||||
|
||||
|
||||
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""두 좌표 간 거리 (미터)."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def _resample(track: list[tuple[float, float]], n: int) -> list[tuple[float, float]]:
|
||||
"""궤적을 n 포인트로 균등 리샘플링 (선형 보간)."""
|
||||
if len(track) == 0:
|
||||
return []
|
||||
if len(track) == 1:
|
||||
return [track[0]] * n
|
||||
if len(track) <= n:
|
||||
return list(track)
|
||||
|
||||
# 누적 거리 계산
|
||||
cumulative = [0.0]
|
||||
for i in range(1, len(track)):
|
||||
d = haversine_m(track[i - 1][0], track[i - 1][1], track[i][0], track[i][1])
|
||||
cumulative.append(cumulative[-1] + d)
|
||||
|
||||
total_dist = cumulative[-1]
|
||||
if total_dist == 0.0:
|
||||
return [track[0]] * n
|
||||
|
||||
step = total_dist / (n - 1)
|
||||
result: list[tuple[float, float]] = []
|
||||
|
||||
seg = 0
|
||||
for k in range(n):
|
||||
target = step * k
|
||||
# 해당 target 거리에 해당하는 선분 찾기
|
||||
while seg < len(cumulative) - 2 and cumulative[seg + 1] < target:
|
||||
seg += 1
|
||||
seg_len = cumulative[seg + 1] - cumulative[seg]
|
||||
if seg_len == 0.0:
|
||||
result.append(track[seg])
|
||||
else:
|
||||
t = (target - cumulative[seg]) / seg_len
|
||||
lat = track[seg][0] + t * (track[seg + 1][0] - track[seg][0])
|
||||
lon = track[seg][1] + t * (track[seg + 1][1] - track[seg][1])
|
||||
result.append((lat, lon))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _dtw_distance(
|
||||
track_a: list[tuple[float, float]],
|
||||
track_b: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""두 궤적 간 DTW 거리 (미터 단위 평균 거리)."""
|
||||
n, m = len(track_a), len(track_b)
|
||||
if n == 0 or m == 0:
|
||||
return float('inf')
|
||||
|
||||
INF = float('inf')
|
||||
# 1D 롤링 DP (공간 최적화)
|
||||
prev = [INF] * (m + 1)
|
||||
prev[0] = 0.0
|
||||
# 첫 행 초기화
|
||||
row = [INF] * (m + 1)
|
||||
row[0] = INF
|
||||
|
||||
dp_prev = [INF] * (m + 1)
|
||||
dp_curr = [INF] * (m + 1)
|
||||
dp_prev[0] = 0.0
|
||||
for j in range(1, m + 1):
|
||||
dp_prev[j] = INF
|
||||
|
||||
for i in range(1, n + 1):
|
||||
dp_curr[0] = INF
|
||||
for j in range(1, m + 1):
|
||||
cost = haversine_m(track_a[i - 1][0], track_a[i - 1][1],
|
||||
track_b[j - 1][0], track_b[j - 1][1])
|
||||
min_prev = min(dp_curr[j - 1], dp_prev[j], dp_prev[j - 1])
|
||||
dp_curr[j] = cost + min_prev
|
||||
dp_prev, dp_curr = dp_curr, [INF] * (m + 1)
|
||||
|
||||
# dp_prev는 마지막으로 계산된 행
|
||||
total = dp_prev[m]
|
||||
if total == INF:
|
||||
return INF
|
||||
return total / (n + m)
|
||||
|
||||
|
||||
def compute_track_similarity(
|
||||
track_a: list[tuple[float, float]],
|
||||
track_b: list[tuple[float, float]],
|
||||
max_dist_m: float = 10000.0,
|
||||
) -> float:
|
||||
"""두 궤적의 DTW 거리 기반 유사도 (0~1).
|
||||
|
||||
track이 비어있으면 0.0 반환.
|
||||
유사할수록 1.0에 가까움.
|
||||
"""
|
||||
if not track_a or not track_b:
|
||||
return 0.0
|
||||
|
||||
a = _resample(track_a, _MAX_RESAMPLE_POINTS)
|
||||
b = _resample(track_b, _MAX_RESAMPLE_POINTS)
|
||||
|
||||
avg_dist = _dtw_distance(a, b)
|
||||
if avg_dist == float('inf') or max_dist_m <= 0.0:
|
||||
return 0.0
|
||||
|
||||
similarity = 1.0 - (avg_dist / max_dist_m)
|
||||
return max(0.0, min(1.0, similarity))
|
||||
|
||||
|
||||
def match_gear_by_track(
|
||||
gear_tracks: dict[str, list[tuple[float, float]]],
|
||||
vessel_tracks: dict[str, list[tuple[float, float]]],
|
||||
threshold: float = 0.6,
|
||||
) -> list[dict]:
|
||||
"""어구 궤적을 선단 선박 궤적과 비교하여 매칭.
|
||||
|
||||
Args:
|
||||
gear_tracks: mmsi → [(lat, lon), ...] — 어구 궤적
|
||||
vessel_tracks: mmsi → [(lat, lon), ...] — 선박 궤적
|
||||
threshold: 유사도 하한 (이상이면 매칭)
|
||||
|
||||
Returns:
|
||||
[{gear_mmsi, vessel_mmsi, similarity, match_method: 'TRACK_SIMILAR'}]
|
||||
"""
|
||||
results: list[dict] = []
|
||||
|
||||
for gear_mmsi, g_track in gear_tracks.items():
|
||||
if not g_track:
|
||||
continue
|
||||
|
||||
best_mmsi: str | None = None
|
||||
best_sim = -1.0
|
||||
|
||||
for vessel_mmsi, v_track in vessel_tracks.items():
|
||||
if not v_track:
|
||||
continue
|
||||
sim = compute_track_similarity(g_track, v_track)
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best_mmsi = vessel_mmsi
|
||||
|
||||
if best_mmsi is not None and best_sim >= threshold:
|
||||
results.append({
|
||||
'gear_mmsi': gear_mmsi,
|
||||
'vessel_mmsi': best_mmsi,
|
||||
'similarity': best_sim,
|
||||
'match_method': 'TRACK_SIMILAR',
|
||||
})
|
||||
|
||||
return results
|
||||
19
prediction/cache/vessel_store.py
vendored
19
prediction/cache/vessel_store.py
vendored
@ -113,12 +113,29 @@ class VesselStore:
|
||||
for mmsi, group in df_all.groupby('mmsi'):
|
||||
self._tracks[str(mmsi)] = group.reset_index(drop=True)
|
||||
|
||||
# last_bucket 설정 — incremental fetch 시작점
|
||||
if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty:
|
||||
max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max()
|
||||
if hasattr(max_bucket, 'to_pydatetime'):
|
||||
max_bucket = max_bucket.to_pydatetime()
|
||||
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None:
|
||||
max_bucket = max_bucket.replace(tzinfo=timezone.utc)
|
||||
self._last_bucket = max_bucket
|
||||
elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty:
|
||||
max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max()
|
||||
if hasattr(max_ts, 'to_pydatetime'):
|
||||
max_ts = max_ts.to_pydatetime()
|
||||
if isinstance(max_ts, datetime) and max_ts.tzinfo is None:
|
||||
max_ts = max_ts.replace(tzinfo=timezone.utc)
|
||||
self._last_bucket = max_ts
|
||||
|
||||
vessel_count = len(self._tracks)
|
||||
point_count = sum(len(v) for v in self._tracks.values())
|
||||
logger.info(
|
||||
'initial load complete: %d vessels, %d total points',
|
||||
'initial load complete: %d vessels, %d total points, last_bucket=%s',
|
||||
vessel_count,
|
||||
point_count,
|
||||
self._last_bucket,
|
||||
)
|
||||
|
||||
self.refresh_static_info()
|
||||
|
||||
1
prediction/data/zones/특정어업수역Ⅰ.json
Normal file
1
prediction/data/zones/특정어업수역Ⅰ.json
Normal file
File diff suppressed because one or more lines are too long
1
prediction/data/zones/특정어업수역Ⅱ.json
Normal file
1
prediction/data/zones/특정어업수역Ⅱ.json
Normal file
File diff suppressed because one or more lines are too long
1
prediction/data/zones/특정어업수역Ⅲ.json
Normal file
1
prediction/data/zones/특정어업수역Ⅲ.json
Normal file
File diff suppressed because one or more lines are too long
1
prediction/data/zones/특정어업수역Ⅳ.json
Normal file
1
prediction/data/zones/특정어업수역Ⅳ.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}
|
||||
322
prediction/fleet_tracker.py
Normal file
322
prediction/fleet_tracker.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""등록 선단 기반 추적기."""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 어구 이름 패턴
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$')
|
||||
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
|
||||
|
||||
_REGISTRY_CACHE_SEC = 3600
|
||||
|
||||
|
||||
class FleetTracker:
|
||||
def __init__(self) -> None:
|
||||
self._companies: dict[int, dict] = {} # id → {name_cn, name_en}
|
||||
self._vessels: dict[int, dict] = {} # id → {permit_no, name_cn, ...}
|
||||
self._name_cn_map: dict[str, int] = {} # name_cn → vessel_id
|
||||
self._name_en_map: dict[str, int] = {} # name_en(lowercase) → vessel_id
|
||||
self._mmsi_to_vid: dict[str, int] = {} # mmsi → vessel_id (매칭된 것만)
|
||||
self._gear_active: dict[str, dict] = {} # mmsi → {name, parent_mmsi, ...}
|
||||
self._last_registry_load: float = 0.0
|
||||
|
||||
def load_registry(self, conn) -> None:
|
||||
"""DB에서 fleet_companies + fleet_vessels 로드. 1시간 캐시."""
|
||||
if time.time() - self._last_registry_load < _REGISTRY_CACHE_SEC:
|
||||
return
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT id, name_cn, name_en FROM kcg.fleet_companies')
|
||||
self._companies = {r[0]: {'name_cn': r[1], 'name_en': r[2]} for r in cur.fetchall()}
|
||||
|
||||
cur.execute(
|
||||
"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage,
|
||||
gear_code, fleet_role, pair_vessel_id, mmsi
|
||||
FROM kcg.fleet_vessels"""
|
||||
)
|
||||
self._vessels = {}
|
||||
self._name_cn_map = {}
|
||||
self._name_en_map = {}
|
||||
self._mmsi_to_vid = {}
|
||||
|
||||
for r in cur.fetchall():
|
||||
vid = r[0]
|
||||
v: dict = {
|
||||
'id': vid,
|
||||
'company_id': r[1],
|
||||
'permit_no': r[2],
|
||||
'name_cn': r[3],
|
||||
'name_en': r[4],
|
||||
'tonnage': r[5],
|
||||
'gear_code': r[6],
|
||||
'fleet_role': r[7],
|
||||
'pair_vessel_id': r[8],
|
||||
'mmsi': r[9],
|
||||
}
|
||||
self._vessels[vid] = v
|
||||
if r[3]:
|
||||
self._name_cn_map[r[3]] = vid
|
||||
if r[4]:
|
||||
self._name_en_map[r[4].lower().strip()] = vid
|
||||
if r[9]:
|
||||
self._mmsi_to_vid[r[9]] = vid
|
||||
|
||||
cur.close()
|
||||
self._last_registry_load = time.time()
|
||||
logger.info(
|
||||
'fleet registry loaded: %d companies, %d vessels',
|
||||
len(self._companies),
|
||||
len(self._vessels),
|
||||
)
|
||||
|
||||
def match_ais_to_registry(self, ais_vessels: list[dict], conn) -> None:
|
||||
"""AIS 선박을 등록 선단에 매칭. DB 업데이트.
|
||||
|
||||
ais_vessels: [{mmsi, name, lat, lon, sog, cog}, ...]
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
matched = 0
|
||||
|
||||
for v in ais_vessels:
|
||||
mmsi = v.get('mmsi', '')
|
||||
name = v.get('name', '')
|
||||
if not mmsi or not name:
|
||||
continue
|
||||
|
||||
# 이미 매칭됨 → last_seen_at 업데이트
|
||||
if mmsi in self._mmsi_to_vid:
|
||||
cur.execute(
|
||||
'UPDATE kcg.fleet_vessels SET last_seen_at = NOW() WHERE id = %s',
|
||||
(self._mmsi_to_vid[mmsi],),
|
||||
)
|
||||
continue
|
||||
|
||||
# NAME_EXACT 매칭
|
||||
vid: Optional[int] = self._name_cn_map.get(name)
|
||||
if not vid:
|
||||
vid = self._name_en_map.get(name.lower().strip())
|
||||
|
||||
if vid:
|
||||
cur.execute(
|
||||
"""UPDATE kcg.fleet_vessels
|
||||
SET mmsi = %s, match_confidence = 0.95, match_method = 'NAME_EXACT',
|
||||
last_seen_at = NOW(), updated_at = NOW()
|
||||
WHERE id = %s AND (mmsi IS NULL OR mmsi = %s)""",
|
||||
(mmsi, vid, mmsi),
|
||||
)
|
||||
self._mmsi_to_vid[mmsi] = vid
|
||||
matched += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
if matched > 0:
|
||||
logger.info('AIS→registry matched: %d vessels', matched)
|
||||
|
||||
def track_gear_identity(self, gear_signals: list[dict], conn) -> None:
|
||||
"""어구/어망 정체성 추적.
|
||||
|
||||
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for g in gear_signals:
|
||||
mmsi = g['mmsi']
|
||||
name = g['name']
|
||||
lat = g.get('lat', 0)
|
||||
lon = g.get('lon', 0)
|
||||
|
||||
# 모선명 + 인덱스 추출
|
||||
parent_name: Optional[str] = None
|
||||
idx1: Optional[int] = None
|
||||
idx2: Optional[int] = None
|
||||
|
||||
m = GEAR_PATTERN.match(name)
|
||||
if m:
|
||||
parent_name = m.group(1).strip()
|
||||
idx1 = int(m.group(2))
|
||||
idx2 = int(m.group(3)) if m.group(3) else None
|
||||
else:
|
||||
m2 = GEAR_PATTERN_PCT.match(name)
|
||||
if m2:
|
||||
parent_name = m2.group(1).strip()
|
||||
|
||||
# 모선 매칭
|
||||
parent_mmsi: Optional[str] = None
|
||||
parent_vid: Optional[int] = None
|
||||
if parent_name:
|
||||
vid = self._name_cn_map.get(parent_name)
|
||||
if not vid:
|
||||
vid = self._name_en_map.get(parent_name.lower())
|
||||
if vid:
|
||||
parent_vid = vid
|
||||
parent_mmsi = self._vessels[vid].get('mmsi')
|
||||
|
||||
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
||||
confidence = 0.9 if parent_vid else 0.0
|
||||
|
||||
# 기존 활성 행 조회
|
||||
cur.execute(
|
||||
"""SELECT id, name FROM kcg.gear_identity_log
|
||||
WHERE mmsi = %s AND is_active = TRUE""",
|
||||
(mmsi,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing[1] == name:
|
||||
# 같은 MMSI + 같은 이름 → 위치/시간 업데이트
|
||||
cur.execute(
|
||||
"""UPDATE kcg.gear_identity_log
|
||||
SET last_seen_at = %s, lat = %s, lon = %s
|
||||
WHERE id = %s""",
|
||||
(now, lat, lon, existing[0]),
|
||||
)
|
||||
else:
|
||||
# 같은 MMSI + 다른 이름 → 이전 비활성화 + 새 행
|
||||
cur.execute(
|
||||
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
|
||||
(existing[0],),
|
||||
)
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.gear_identity_log
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
|
||||
gear_index_1, gear_index_2, lat, lon,
|
||||
match_method, match_confidence, first_seen_at, last_seen_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vid,
|
||||
idx1, idx2, lat, lon,
|
||||
match_method, confidence, now, now),
|
||||
)
|
||||
else:
|
||||
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
|
||||
cur.execute(
|
||||
"""SELECT id, mmsi FROM kcg.gear_identity_log
|
||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||
(name, mmsi),
|
||||
)
|
||||
old_mmsi_row = cur.fetchone()
|
||||
if old_mmsi_row:
|
||||
# 같은 이름 + 다른 MMSI → MMSI 변경
|
||||
cur.execute(
|
||||
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
|
||||
(old_mmsi_row[0],),
|
||||
)
|
||||
logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name)
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.gear_identity_log
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
|
||||
gear_index_1, gear_index_2, lat, lon,
|
||||
match_method, match_confidence, first_seen_at, last_seen_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vid,
|
||||
idx1, idx2, lat, lon,
|
||||
match_method, confidence, now, now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
||||
"""등록 선단 기준으로 cluster 정보 구성.
|
||||
|
||||
Returns: {mmsi → {cluster_id, cluster_size, is_leader, fleet_role}}
|
||||
cluster_id = company_id (등록 선단 기준)
|
||||
"""
|
||||
results: dict[str, dict] = {}
|
||||
|
||||
# 회사별로 현재 AIS 수신 중인 선박 그룹핑
|
||||
company_vessels: dict[int, list[str]] = {}
|
||||
for mmsi, vid in self._mmsi_to_vid.items():
|
||||
v = self._vessels.get(vid)
|
||||
if not v or mmsi not in vessel_dfs:
|
||||
continue
|
||||
cid = v['company_id']
|
||||
company_vessels.setdefault(cid, []).append(mmsi)
|
||||
|
||||
for cid, mmsis in company_vessels.items():
|
||||
if len(mmsis) < 2:
|
||||
# 단독 선박 → NOISE
|
||||
for mmsi in mmsis:
|
||||
v = self._vessels.get(self._mmsi_to_vid.get(mmsi, -1), {})
|
||||
results[mmsi] = {
|
||||
'cluster_id': -1,
|
||||
'cluster_size': 1,
|
||||
'is_leader': False,
|
||||
'fleet_role': v.get('fleet_role', 'NOISE'),
|
||||
}
|
||||
continue
|
||||
|
||||
# 2척 이상 → 등록 선단 클러스터
|
||||
for mmsi in mmsis:
|
||||
vid = self._mmsi_to_vid[mmsi]
|
||||
v = self._vessels[vid]
|
||||
results[mmsi] = {
|
||||
'cluster_id': cid,
|
||||
'cluster_size': len(mmsis),
|
||||
'is_leader': v['fleet_role'] == 'MAIN',
|
||||
'fleet_role': v['fleet_role'],
|
||||
}
|
||||
|
||||
# 매칭 안 된 선박 → NOISE
|
||||
for mmsi in vessel_dfs:
|
||||
if mmsi not in results:
|
||||
results[mmsi] = {
|
||||
'cluster_id': -1,
|
||||
'cluster_size': 0,
|
||||
'is_leader': False,
|
||||
'fleet_role': 'NOISE',
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def save_snapshot(self, vessel_dfs: dict[str, pd.DataFrame], conn) -> None:
|
||||
"""fleet_tracking_snapshot 저장."""
|
||||
now = datetime.now(timezone.utc)
|
||||
cur = conn.cursor()
|
||||
|
||||
company_vessels: dict[int, list[str]] = {}
|
||||
for mmsi, vid in self._mmsi_to_vid.items():
|
||||
v = self._vessels.get(vid)
|
||||
if not v or mmsi not in vessel_dfs:
|
||||
continue
|
||||
company_vessels.setdefault(v['company_id'], []).append(mmsi)
|
||||
|
||||
for cid, mmsis in company_vessels.items():
|
||||
active = len(mmsis)
|
||||
total = sum(1 for v in self._vessels.values() if v['company_id'] == cid)
|
||||
|
||||
lats: list[float] = []
|
||||
lons: list[float] = []
|
||||
for mmsi in mmsis:
|
||||
df = vessel_dfs.get(mmsi)
|
||||
if df is not None and len(df) > 0:
|
||||
last = df.iloc[-1]
|
||||
lats.append(float(last['lat']))
|
||||
lons.append(float(last['lon']))
|
||||
|
||||
center_lat = sum(lats) / len(lats) if lats else None
|
||||
center_lon = sum(lons) / len(lons) if lons else None
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.fleet_tracking_snapshot
|
||||
(company_id, snapshot_time, total_vessels, active_vessels,
|
||||
center_lat, center_lon)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(cid, now, total, active, center_lat, center_lon),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.info('fleet snapshot saved: %d companies', len(company_vessels))
|
||||
|
||||
|
||||
# 싱글턴
|
||||
fleet_tracker = FleetTracker()
|
||||
@ -56,29 +56,41 @@ class AnalysisResult:
|
||||
|
||||
def to_db_tuple(self) -> tuple:
|
||||
import json
|
||||
|
||||
def _f(v: object) -> float:
|
||||
"""numpy float → Python float 변환."""
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
def _i(v: object) -> int:
|
||||
"""numpy int → Python int 변환."""
|
||||
return int(v) if v is not None else 0
|
||||
|
||||
# features dict 내부 numpy 값도 변환
|
||||
safe_features = {k: float(v) for k, v in self.features.items()} if self.features else {}
|
||||
|
||||
return (
|
||||
self.mmsi,
|
||||
str(self.mmsi),
|
||||
self.timestamp,
|
||||
self.vessel_type,
|
||||
self.confidence,
|
||||
self.fishing_pct,
|
||||
self.cluster_id,
|
||||
self.season,
|
||||
self.zone,
|
||||
self.dist_to_baseline_nm,
|
||||
self.activity_state,
|
||||
self.ucaf_score,
|
||||
self.ucft_score,
|
||||
self.is_dark,
|
||||
self.gap_duration_min,
|
||||
self.spoofing_score,
|
||||
self.bd09_offset_m,
|
||||
self.speed_jump_count,
|
||||
self.cluster_size,
|
||||
self.is_leader,
|
||||
self.fleet_role,
|
||||
self.risk_score,
|
||||
self.risk_level,
|
||||
json.dumps(self.features),
|
||||
str(self.vessel_type),
|
||||
_f(self.confidence),
|
||||
_f(self.fishing_pct),
|
||||
_i(self.cluster_id),
|
||||
str(self.season),
|
||||
str(self.zone),
|
||||
_f(self.dist_to_baseline_nm),
|
||||
str(self.activity_state),
|
||||
_f(self.ucaf_score),
|
||||
_f(self.ucft_score),
|
||||
bool(self.is_dark),
|
||||
_i(self.gap_duration_min),
|
||||
_f(self.spoofing_score),
|
||||
_f(self.bd09_offset_m),
|
||||
_i(self.speed_jump_count),
|
||||
_i(self.cluster_size),
|
||||
bool(self.is_leader),
|
||||
str(self.fleet_role),
|
||||
_i(self.risk_score),
|
||||
str(self.risk_level),
|
||||
json.dumps(safe_features),
|
||||
self.analyzed_at,
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ def get_last_run() -> dict:
|
||||
|
||||
def run_analysis_cycle():
|
||||
"""5분 주기 분석 사이클 — 인메모리 캐시 기반."""
|
||||
import re as _re
|
||||
from cache.vessel_store import vessel_store
|
||||
from db import snpdb, kcgdb
|
||||
from pipeline.orchestrator import ChineseFishingVesselPipeline
|
||||
@ -32,8 +33,8 @@ def run_analysis_cycle():
|
||||
from algorithms.fishing_pattern import compute_ucaf_score, compute_ucft_score
|
||||
from algorithms.dark_vessel import is_dark_vessel
|
||||
from algorithms.spoofing import compute_spoofing_score, count_speed_jumps, compute_bd09_offset
|
||||
from algorithms.fleet import assign_fleet_roles
|
||||
from algorithms.risk import compute_vessel_risk_score
|
||||
from fleet_tracker import fleet_tracker
|
||||
from models.result import AnalysisResult
|
||||
|
||||
start = time.time()
|
||||
@ -71,9 +72,30 @@ def run_analysis_cycle():
|
||||
_last_run['vessel_count'] = 0
|
||||
return
|
||||
|
||||
# 4. 선단 역할 분석
|
||||
cluster_map = {c['mmsi']: c['cluster_id'] for c in classifications}
|
||||
fleet_roles = assign_fleet_roles(vessel_dfs, cluster_map)
|
||||
# 4. 등록 선단 기반 fleet 분석
|
||||
_gear_re = _re.compile(r'^.+_\d+_\d*$|%$')
|
||||
with kcgdb.get_conn() as kcg_conn:
|
||||
fleet_tracker.load_registry(kcg_conn)
|
||||
|
||||
all_ais = []
|
||||
for mmsi, df in vessel_dfs.items():
|
||||
if len(df) > 0:
|
||||
last = df.iloc[-1]
|
||||
all_ais.append({
|
||||
'mmsi': mmsi,
|
||||
'name': vessel_store.get_vessel_info(mmsi).get('name', ''),
|
||||
'lat': float(last['lat']),
|
||||
'lon': float(last['lon']),
|
||||
})
|
||||
|
||||
fleet_tracker.match_ais_to_registry(all_ais, kcg_conn)
|
||||
|
||||
gear_signals = [v for v in all_ais if _gear_re.match(v.get('name', ''))]
|
||||
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
||||
|
||||
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
||||
|
||||
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
||||
|
||||
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
|
||||
results = []
|
||||
@ -116,7 +138,7 @@ def run_analysis_cycle():
|
||||
vessel_type=c['vessel_type'],
|
||||
confidence=c['confidence'],
|
||||
fishing_pct=c['fishing_pct'],
|
||||
cluster_id=c['cluster_id'],
|
||||
cluster_id=fleet_info.get('cluster_id', -1),
|
||||
season=c['season'],
|
||||
zone=zone_info.get('zone', 'EEZ_OR_BEYOND'),
|
||||
dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0),
|
||||
|
||||
176
prediction/scripts/load_fleet_registry.py
Normal file
176
prediction/scripts/load_fleet_registry.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""선단 구성 JSX → kcgdb fleet_companies + fleet_vessels 적재.
|
||||
|
||||
Usage: python3 prediction/scripts/load_fleet_registry.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
# JSX 파일에서 D 배열 추출
|
||||
JSX_PATH = Path(__file__).parent.parent.parent.parent / 'gc-wing-dev' / 'legacy' / '선단구성_906척_어업수역 (1).jsx'
|
||||
|
||||
# kcgdb 접속 — prediction/.env 또는 환경변수
|
||||
DB_HOST = '211.208.115.83'
|
||||
DB_PORT = 5432
|
||||
DB_NAME = 'kcgdb'
|
||||
DB_USER = 'kcg_app'
|
||||
DB_SCHEMA = 'kcg'
|
||||
|
||||
|
||||
def parse_jsx(path: Path) -> list[list]:
|
||||
"""JSX 파일에서 D=[ ... ] 배열을 파싱."""
|
||||
text = path.read_text(encoding='utf-8')
|
||||
|
||||
# const D=[ 부터 ]; 까지 추출
|
||||
m = re.search(r'const\s+D\s*=\s*\[', text)
|
||||
if not m:
|
||||
raise ValueError('D 배열을 찾을 수 없습니다')
|
||||
|
||||
start = m.end() - 1 # [ 위치
|
||||
# 중첩 배열을 추적하여 닫는 ] 찾기
|
||||
depth = 0
|
||||
end = start
|
||||
for i in range(start, len(text)):
|
||||
if text[i] == '[':
|
||||
depth += 1
|
||||
elif text[i] == ']':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
|
||||
raw = text[start:end]
|
||||
|
||||
# JavaScript → JSON 변환 (trailing comma 제거)
|
||||
raw = re.sub(r',\s*]', ']', raw)
|
||||
raw = re.sub(r',\s*}', '}', raw)
|
||||
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def load_to_db(data: list[list], db_password: str):
|
||||
"""파싱된 데이터를 DB에 적재."""
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||
user=DB_USER, password=db_password,
|
||||
options=f'-c search_path={DB_SCHEMA}',
|
||||
)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# 기존 데이터 초기화
|
||||
cur.execute('DELETE FROM fleet_vessels')
|
||||
cur.execute('DELETE FROM fleet_companies')
|
||||
|
||||
company_count = 0
|
||||
vessel_count = 0
|
||||
pair_links = [] # (vessel_id, pair_vessel_id) 후처리
|
||||
|
||||
for row in data:
|
||||
if len(row) < 7:
|
||||
continue
|
||||
|
||||
name_cn = row[0]
|
||||
name_en = row[1]
|
||||
|
||||
# 회사 INSERT
|
||||
cur.execute(
|
||||
'INSERT INTO fleet_companies (name_cn, name_en) VALUES (%s, %s) RETURNING id',
|
||||
(name_cn, name_en),
|
||||
)
|
||||
company_id = cur.fetchone()[0]
|
||||
company_count += 1
|
||||
|
||||
# 인덱스: 0=own, 1=ownEn, 2=pairs, 3=gn, 4=ot, 5=ps, 6=fc, 7=upt, 8=upts
|
||||
pairs = row[2] if len(row) > 2 and isinstance(row[2], list) else []
|
||||
gn = row[3] if len(row) > 3 and isinstance(row[3], list) else []
|
||||
ot = row[4] if len(row) > 4 and isinstance(row[4], list) else []
|
||||
ps = row[5] if len(row) > 5 and isinstance(row[5], list) else []
|
||||
fc = row[6] if len(row) > 6 and isinstance(row[6], list) else []
|
||||
upt = row[7] if len(row) > 7 and isinstance(row[7], list) else []
|
||||
upts = row[8] if len(row) > 8 and isinstance(row[8], list) else []
|
||||
|
||||
def insert_vessel(v, gear_code, role):
|
||||
nonlocal vessel_count
|
||||
if not isinstance(v, list) or len(v) < 4:
|
||||
return None
|
||||
cur.execute(
|
||||
'''INSERT INTO fleet_vessels
|
||||
(company_id, permit_no, name_cn, name_en, tonnage, gear_code, fleet_role)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id''',
|
||||
(company_id, v[0], v[1], v[2], v[3], gear_code, role),
|
||||
)
|
||||
vessel_count += 1
|
||||
return cur.fetchone()[0]
|
||||
|
||||
# PT 본선쌍 (pairs)
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, list) or len(pair) < 2:
|
||||
continue
|
||||
main_id = insert_vessel(pair[0], 'C21', 'MAIN')
|
||||
sub_id = insert_vessel(pair[1], 'C21', 'SUB')
|
||||
if main_id and sub_id:
|
||||
pair_links.append((main_id, sub_id))
|
||||
|
||||
# GN 유자망
|
||||
for v in gn:
|
||||
insert_vessel(v, 'C25', 'GN')
|
||||
|
||||
# OT 기타
|
||||
for v in ot:
|
||||
insert_vessel(v, 'C22', 'OT')
|
||||
|
||||
# PS 선망
|
||||
for v in ps:
|
||||
insert_vessel(v, 'C23', 'PS')
|
||||
|
||||
# FC 운반선
|
||||
for v in fc:
|
||||
insert_vessel(v, 'C40', 'FC')
|
||||
|
||||
# UPT 단독 본선
|
||||
for v in upt:
|
||||
insert_vessel(v, 'C21', 'MAIN_SOLO')
|
||||
|
||||
# UPTS 단독 부속선
|
||||
for v in upts:
|
||||
insert_vessel(v, 'C21', 'SUB_SOLO')
|
||||
|
||||
# PT 쌍 상호 참조 설정
|
||||
for main_id, sub_id in pair_links:
|
||||
cur.execute('UPDATE fleet_vessels SET pair_vessel_id = %s WHERE id = %s', (sub_id, main_id))
|
||||
cur.execute('UPDATE fleet_vessels SET pair_vessel_id = %s WHERE id = %s', (main_id, sub_id))
|
||||
|
||||
conn.commit()
|
||||
print(f'적재 완료: {company_count}개 회사, {vessel_count}척 선박, {len(pair_links)}쌍 PT')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f'적재 실패: {e}', file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not JSX_PATH.exists():
|
||||
print(f'파일을 찾을 수 없습니다: {JSX_PATH}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# DB 비밀번호 — 환경변수 또는 직접 입력
|
||||
import os
|
||||
password = os.environ.get('KCGDB_PASSWORD', 'Kcg2026monitor')
|
||||
|
||||
print(f'JSX 파싱: {JSX_PATH}')
|
||||
data = parse_jsx(JSX_PATH)
|
||||
print(f'파싱 완료: {len(data)}개 회사')
|
||||
|
||||
print('DB 적재 시작...')
|
||||
load_to_db(data, password)
|
||||
불러오는 중...
Reference in New Issue
Block a user