From bf9c0bd34623e9a4d17ad883fbf1184013dd45f7 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:43:58 +0900 Subject: [PATCH 01/46] =?UTF-8?q?feat:=20OpenSky=20OAuth2=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20+=20=EC=88=98=EC=A7=91=20=EC=A3=BC=EA=B8=B0=205?= =?UTF-8?q?=EB=B6=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth2 Client Credentials 토큰 관리 (30분 유효, 자동 갱신) - 수집 주기 60초 → 300초 (일일 크레딧 11,520 → 2,304) - AppProperties: openSkyClientId/Secret/AuthUrl 설정 추가 - application-prod.yml: 환경변수 참조 (OPENSKY_CLIENT_ID/SECRET) - 미설정 시 익명 모드 폴백 유지 --- .../collector/aircraft/OpenSkyCollector.java | 77 ++++++++++++++++--- .../java/gc/mda/kcg/config/AppProperties.java | 3 + .../resources/application-local.yml.example | 3 + .../src/main/resources/application-prod.yml | 3 + 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java index aa9a773..126b596 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java @@ -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 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 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 fetchStates(String bboxParams) { try { - String url = BASE_URL + "/states/all?" + bboxParams; - ResponseEntity 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 entity = new HttpEntity<>(headers); + + ResponseEntity 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 body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity 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 aircraft, String source, String region) { try { Instant now = Instant.now(); diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 5fb83d8..2f144dc 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -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"; diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example index 0fa5862..70490de 100644 --- a/backend/src/main/resources/application-local.yml.example +++ b/backend/src/main/resources/application-local.yml.example @@ -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 diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index b62d921..71ae3c5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -11,5 +11,8 @@ app: client-id: ${GOOGLE_CLIENT_ID} auth: allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr} + collector: + open-sky-client-id: ${OPENSKY_CLIENT_ID:} + open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:} cors: allowed-origins: http://localhost:5173,https://kcg.gc-si.dev From 0b3775a251212178f1d9b94852531484dd958902 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:44:21 +0900 Subject: [PATCH 02/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 2c5f029..6fd1d11 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신) + +### 변경 +- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304) + ## [2026-03-19] ### 변경 From dcff31002d4315078681dc113a2295713051b2c7 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:45:10 +0900 Subject: [PATCH 03/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-19.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6fd1d11..ecfa1ff 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-19.2] + ### 추가 - OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신) From 712d7c12ff3e7dfa8982cc0dc4d31911a49b0113 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:56:11 +0900 Subject: [PATCH 04/46] =?UTF-8?q?fix:=20GDELT=20=EC=BF=BC=EB=A6=AC=20URL?= =?UTF-8?q?=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=ED=95=9C=EA=B8=80/=ED=8A=B9=EC=88=98=EB=AC=B8=EC=9E=90=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=90=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/gc/mda/kcg/collector/osint/OsintCollector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java index e58b2b9..91952a5 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -265,6 +265,6 @@ public class OsintCollector { } private String encodeQuery(String query) { - return query.replace(" ", "+"); + return java.net.URLEncoder.encode(query, StandardCharsets.UTF_8); } } From b1daa3691116d148f63270d1bf6fc942bace5ca1 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 11:02:28 +0900 Subject: [PATCH 05/46] =?UTF-8?q?ci:=20deploy.yml=EC=97=90=20OpenSky=20?= =?UTF-8?q?=ED=81=AC=EB=A0=88=EB=8D=B4=EC=85=9C=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fed34a5..3569db2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 # JAR 내부에 application-prod.yml이 있으면 외부 파일 제거 if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then From 5a2675a1d5be46df698ee11c21c0eb4886880f09 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 11:49:38 +0900 Subject: [PATCH 06/46] =?UTF-8?q?fix:=20OSINT=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=A5=BC=20title=20=EB=8B=A8=EB=8F=85=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gc/mda/kcg/collector/osint/OsintCollector.java | 6 ++---- .../java/gc/mda/kcg/domain/osint/OsintFeedRepository.java | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java index 91952a5..386c067 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -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); @@ -184,8 +183,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); diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java index b87a231..d3355aa 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -9,7 +9,7 @@ public interface OsintFeedRepository extends JpaRepository { boolean existsBySourceAndSourceUrl(String source, String sourceUrl); - boolean existsByRegionAndTitleAndCollectedAtAfter(String region, String title, Instant since); + boolean existsByTitle(String title); List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); } From 0c6d626b363e5deb9a221b2b476c26138ac17fee Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 13:06:58 +0900 Subject: [PATCH 07/46] =?UTF-8?q?fix:=20OSINT=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=A7=80=20=E2=80=94=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20save=20try-catch=20+=20DB=20UNIQUE(title)=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mda/kcg/collector/osint/OsintCollector.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java index 386c067..deb437f 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -139,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; @@ -199,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; From a68dfb21b24595cba38e639f6f69573d35ef0f5a Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:07:19 +0900 Subject: [PATCH 08/46] =?UTF-8?q?feat:=20Python=20=EC=96=B4=EC=84=A0=20?= =?UTF-8?q?=EB=B6=84=EB=A5=98=EA=B8=B0=20+=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20+=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prediction/: FastAPI 7단계 분류 파이프라인 + 6개 탐지 알고리즘 - snpdb 궤적 조회 → 인메모리 캐시(13K척) → 분류 → kcgdb 저장 - APScheduler 5분 주기, Python 3.9 호환 - 버그 수정: @property last_bucket, SQL INTERVAL 바인딩, rollback, None 가드 - 보안: DB 비밀번호 하드코딩 제거 → env 환경변수 필수 - deploy/kcg-prediction.service: systemd 서비스 (redis-211, 포트 8001) - deploy.yml: prediction CI/CD 배포 단계 추가 (192.168.1.18:32023) - backend: PredictionProxyController (health/status/trigger 프록시) - backend: AppProperties predictionBaseUrl + AuthFilter 인증 예외 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/deploy.yml | 74 ++++ .../main/java/gc/mda/kcg/auth/AuthFilter.java | 6 +- .../java/gc/mda/kcg/config/AppProperties.java | 1 + .../analysis/PredictionProxyController.java | 80 +++++ .../src/main/resources/application-prod.yml | 1 + .../migration/006_vessel_analysis_unique.sql | 3 + deploy/kcg-prediction.service | 15 + prediction/algorithms/__init__.py | 0 prediction/algorithms/dark_vessel.py | 59 +++ prediction/algorithms/fishing_pattern.py | 117 ++++++ prediction/algorithms/fleet.py | 152 ++++++++ prediction/algorithms/location.py | 93 +++++ prediction/algorithms/risk.py | 75 ++++ prediction/algorithms/spoofing.py | 80 +++++ prediction/cache/__init__.py | 0 prediction/cache/vessel_store.py | 335 ++++++++++++++++++ prediction/config.py | 40 +++ prediction/data/korea_baseline.json | 1 + prediction/db/__init__.py | 0 prediction/db/kcgdb.py | 134 +++++++ prediction/db/snpdb.py | 187 ++++++++++ prediction/env.example | 25 ++ prediction/main.py | 66 +++- prediction/models/__init__.py | 0 prediction/models/ais.py | 38 ++ prediction/models/result.py | 84 +++++ prediction/pipeline/__init__.py | 0 prediction/pipeline/behavior.py | 31 ++ prediction/pipeline/classifier.py | 100 ++++++ prediction/pipeline/clusterer.py | 101 ++++++ prediction/pipeline/constants.py | 26 ++ prediction/pipeline/features.py | 93 +++++ prediction/pipeline/orchestrator.py | 95 +++++ prediction/pipeline/preprocessor.py | 52 +++ prediction/pipeline/resampler.py | 35 ++ prediction/requirements.txt | 6 + prediction/scheduler.py | 177 +++++++++ 37 files changed, 2377 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java create mode 100644 database/migration/006_vessel_analysis_unique.sql create mode 100644 deploy/kcg-prediction.service create mode 100644 prediction/algorithms/__init__.py create mode 100644 prediction/algorithms/dark_vessel.py create mode 100644 prediction/algorithms/fishing_pattern.py create mode 100644 prediction/algorithms/fleet.py create mode 100644 prediction/algorithms/location.py create mode 100644 prediction/algorithms/risk.py create mode 100644 prediction/algorithms/spoofing.py create mode 100644 prediction/cache/__init__.py create mode 100644 prediction/cache/vessel_store.py create mode 100644 prediction/config.py create mode 100644 prediction/data/korea_baseline.json create mode 100644 prediction/db/__init__.py create mode 100644 prediction/db/kcgdb.py create mode 100644 prediction/db/snpdb.py create mode 100644 prediction/env.example create mode 100644 prediction/models/__init__.py create mode 100644 prediction/models/ais.py create mode 100644 prediction/models/result.py create mode 100644 prediction/pipeline/__init__.py create mode 100644 prediction/pipeline/behavior.py create mode 100644 prediction/pipeline/classifier.py create mode 100644 prediction/pipeline/clusterer.py create mode 100644 prediction/pipeline/constants.py create mode 100644 prediction/pipeline/features.py create mode 100644 prediction/pipeline/orchestrator.py create mode 100644 prediction/pipeline/preprocessor.py create mode 100644 prediction/pipeline/resampler.py create mode 100644 prediction/scheduler.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3569db2..2140272 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -72,6 +72,7 @@ jobs: [ -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이 있으면 외부 파일 제거 if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then @@ -147,6 +148,79 @@ jobs: sleep 10 done + # ═══ Prediction (FastAPI → redis-211) ═══ + - name: Deploy prediction via SSH + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + PRED_HOST: 192.168.1.18 + PRED_PORT: 32023 + run: | + mkdir -p ~/.ssh + printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + + SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -p $PRED_PORT" + SCP_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -P $PRED_PORT" + + REMOTE_DIR=/home/apps/kcg-prediction + + # 코드 전송 (rsync 대체: tar + scp) + tar czf /tmp/prediction.tar.gz -C prediction --exclude='__pycache__' --exclude='venv' --exclude='.env' . + for attempt in 1 2 3; do + echo "SCP prediction attempt $attempt/3..." + if scp $SCP_OPTS /tmp/prediction.tar.gz root@$PRED_HOST:/tmp/prediction.tar.gz; then break; fi + [ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed"; exit 1; } + sleep 10 + done + + # 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 + + # 코드 배포 + tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR + rm -f /tmp/prediction.tar.gz + + # 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 + 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 + SCRIPT + then exit 0; fi + [ "$attempt" -eq 3 ] && { echo "ERROR: SSH failed"; exit 1; } + sleep 10 + done + - name: Cleanup if: always() run: rm -f ~/.ssh/id_deploy diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index de1aa74..8b172c1 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -23,6 +23,8 @@ public class AuthFilter extends OncePerRequestFilter { private static final String AUTH_PATH_PREFIX = "/api/auth/"; private static final String SENSOR_PATH_PREFIX = "/api/sensor/"; 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 final JwtProvider jwtProvider; @@ -31,7 +33,9 @@ public class AuthFilter extends OncePerRequestFilter { String path = request.getRequestURI(); return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX) - || path.startsWith(CCTV_PATH_PREFIX); + || path.startsWith(CCTV_PATH_PREFIX) + || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX) + || path.startsWith(PREDICTION_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 2f144dc..c72785d 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -46,6 +46,7 @@ public class AppProperties { 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"; + private String predictionBaseUrl = "http://localhost:8001"; private int requestDelayMs = 1500; private int backoffMs = 5000; } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java new file mode 100644 index 0000000..f13b8a2 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java @@ -0,0 +1,80 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.config.AppProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * Prediction 서비스(FastAPI) 상태/트리거 프록시 + * GET /api/prediction/health → {predictionBaseUrl}/health + * GET /api/prediction/status → {predictionBaseUrl}/api/v1/analysis/status + * POST /api/prediction/trigger → {predictionBaseUrl}/api/v1/analysis/trigger + */ +@Slf4j +@RestController +@RequestMapping("/api/prediction") +@RequiredArgsConstructor +public class PredictionProxyController { + + private final RestTemplate restTemplate; + private final AppProperties appProperties; + + @GetMapping("/health") + public ResponseEntity health() { + String url = appProperties.getCollector().getPredictionBaseUrl() + "/health"; + return proxyGet(url); + } + + @GetMapping("/status") + public ResponseEntity status() { + String url = appProperties.getCollector().getPredictionBaseUrl() + "/api/v1/analysis/status"; + return proxyGet(url); + } + + @PostMapping("/trigger") + public ResponseEntity trigger(@RequestBody(required = false) Object body) { + String url = appProperties.getCollector().getPredictionBaseUrl() + "/api/v1/analysis/trigger"; + return proxyPost(url, body); + } + + private ResponseEntity proxyGet(String url) { + try { + ResponseEntity upstream = restTemplate.exchange( + url, HttpMethod.GET, null, Object.class); + return ResponseEntity.status(upstream.getStatusCode()).body(upstream.getBody()); + } catch (ResourceAccessException e) { + log.warn("Prediction 서비스 접속 불가: {}", e.getMessage()); + return unreachable(e.getMessage()); + } catch (RestClientException e) { + log.warn("Prediction 서비스 오류: {}", e.getMessage()); + return unreachable(e.getMessage()); + } + } + + private ResponseEntity proxyPost(String url, Object body) { + try { + HttpEntity entity = new HttpEntity<>(body); + ResponseEntity upstream = restTemplate.exchange( + url, HttpMethod.POST, entity, Object.class); + return ResponseEntity.status(upstream.getStatusCode()).body(upstream.getBody()); + } catch (ResourceAccessException e) { + log.warn("Prediction 서비스 접속 불가: {}", e.getMessage()); + return unreachable(e.getMessage()); + } catch (RestClientException e) { + log.warn("Prediction 서비스 오류: {}", e.getMessage()); + return unreachable(e.getMessage()); + } + } + + private ResponseEntity unreachable(String message) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("status", "unreachable", "error", message != null ? message : "connection failed")); + } +} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 71ae3c5..89792f5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -14,5 +14,6 @@ app: 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 diff --git a/database/migration/006_vessel_analysis_unique.sql b/database/migration/006_vessel_analysis_unique.sql new file mode 100644 index 0000000..6ecf39c --- /dev/null +++ b/database/migration/006_vessel_analysis_unique.sql @@ -0,0 +1,3 @@ +-- UPSERT를 위한 UNIQUE 인덱스 추가 +CREATE UNIQUE INDEX IF NOT EXISTS idx_vessel_analysis_mmsi_ts + ON kcg.vessel_analysis_results(mmsi, timestamp); diff --git a/deploy/kcg-prediction.service b/deploy/kcg-prediction.service new file mode 100644 index 0000000..ff007bc --- /dev/null +++ b/deploy/kcg-prediction.service @@ -0,0 +1,15 @@ +[Unit] +Description=KCG Vessel Analysis (FastAPI) +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/home/apps/kcg-prediction +ExecStart=/home/apps/kcg-prediction/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8001 +EnvironmentFile=/home/apps/kcg-prediction/.env +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/prediction/algorithms/__init__.py b/prediction/algorithms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prediction/algorithms/dark_vessel.py b/prediction/algorithms/dark_vessel.py new file mode 100644 index 0000000..9e8b9f2 --- /dev/null +++ b/prediction/algorithms/dark_vessel.py @@ -0,0 +1,59 @@ +import pandas as pd +from algorithms.location import haversine_nm + +GAP_SUSPICIOUS_SEC = 1800 # 30분 +GAP_HIGH_SUSPICIOUS_SEC = 3600 # 1시간 +GAP_VIOLATION_SEC = 86400 # 24시간 + + +def detect_ais_gaps(df_vessel: pd.DataFrame) -> list[dict]: + """AIS 수신 기록에서 소실 구간 추출.""" + if len(df_vessel) < 2: + return [] + + gaps = [] + records = df_vessel.sort_values('timestamp').to_dict('records') + + for i in range(1, len(records)): + prev, curr = records[i - 1], records[i] + prev_ts = pd.Timestamp(prev['timestamp']) + curr_ts = pd.Timestamp(curr['timestamp']) + gap_sec = (curr_ts - prev_ts).total_seconds() + + if gap_sec < GAP_SUSPICIOUS_SEC: + continue + + disp = haversine_nm( + prev['lat'], prev['lon'], + curr['lat'], curr['lon'], + ) + + if gap_sec >= GAP_VIOLATION_SEC: + severity = 'VIOLATION' + elif gap_sec >= GAP_HIGH_SUSPICIOUS_SEC: + severity = 'HIGH_SUSPICIOUS' + else: + severity = 'SUSPICIOUS' + + gaps.append({ + 'gap_sec': int(gap_sec), + 'gap_min': round(gap_sec / 60, 1), + 'displacement_nm': round(disp, 2), + 'severity': severity, + }) + + return gaps + + +def is_dark_vessel(df_vessel: pd.DataFrame) -> tuple[bool, int]: + """다크베셀 여부 판정. + + Returns: (is_dark, max_gap_duration_min) + """ + gaps = detect_ais_gaps(df_vessel) + if not gaps: + return False, 0 + + max_gap_min = max(g['gap_min'] for g in gaps) + is_dark = max_gap_min >= 30 # 30분 이상 소실 + return is_dark, int(max_gap_min) diff --git a/prediction/algorithms/fishing_pattern.py b/prediction/algorithms/fishing_pattern.py new file mode 100644 index 0000000..c2815ec --- /dev/null +++ b/prediction/algorithms/fishing_pattern.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import pandas as pd +from algorithms.location import haversine_nm, classify_zone # noqa: F401 (haversine_nm re-exported for callers) + +# Yan et al. (2022) 어구별 조업 속도 임계값 +GEAR_SOG_THRESHOLDS: dict[str, tuple[float, float]] = { + 'PT': (2.5, 4.5), # 쌍끌이저인망 + 'OT': (2.0, 4.0), # 단선저인망 + 'GN': (0.5, 2.5), # 자망·유망 + 'SQ': (0.0, 1.0), # 오징어채낚기 + 'TRAP': (0.3, 1.5), # 통발 + 'PS': (3.0, 6.0), # 선망 + 'TRAWL': (2.0, 4.5), # (alias) + 'PURSE': (3.0, 6.0), # (alias) + 'LONGLINE': (0.5, 2.5), +} +TRANSIT_SOG_MIN = 5.0 +ANCHORED_SOG_MAX = 0.5 + + +def classify_vessel_state(sog: float, cog_delta: float = 0.0, + gear_type: str = 'PT') -> str: + """UCAF: 어구별 상태 분류.""" + if sog <= ANCHORED_SOG_MAX: + return 'ANCHORED' + if sog >= TRANSIT_SOG_MIN: + return 'TRANSIT' + sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0)) + if sog_min <= sog <= sog_max: + return 'FISHING' + return 'UNKNOWN' + + +def compute_ucaf_score(df_vessel: pd.DataFrame, gear_type: str = 'PT') -> float: + """UCAF 점수: 어구별 조업 상태 비율 (0~1).""" + if len(df_vessel) == 0: + return 0.0 + sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0)) + in_range = df_vessel['sog'].between(sog_min, sog_max).sum() + return round(in_range / len(df_vessel), 4) + + +def compute_ucft_score(df_vessel: pd.DataFrame) -> float: + """UCFT 점수: 조업 vs 항행 이진 신뢰도 (0~1).""" + if len(df_vessel) == 0: + return 0.0 + fishing = (df_vessel['sog'].between(0.5, 5.0)).sum() + transit = (df_vessel['sog'] >= TRANSIT_SOG_MIN).sum() + total = fishing + transit + if total == 0: + return 0.0 + return round(fishing / total, 4) + + +def detect_fishing_segments(df_vessel: pd.DataFrame, + window_min: int = 15, + gear_type: str = 'PT') -> list[dict]: + """연속 조업 구간 추출.""" + if len(df_vessel) < 2: + return [] + + segments: list[dict] = [] + in_fishing = False + seg_start_idx = 0 + + records = df_vessel.to_dict('records') + for i, rec in enumerate(records): + sog = rec.get('sog', 0) + state = classify_vessel_state(sog, gear_type=gear_type) + + if state == 'FISHING' and not in_fishing: + in_fishing = True + seg_start_idx = i + elif state != 'FISHING' and in_fishing: + start_ts = records[seg_start_idx].get('timestamp') + end_ts = rec.get('timestamp') + if start_ts and end_ts: + dur_sec = (pd.Timestamp(end_ts) - pd.Timestamp(start_ts)).total_seconds() + dur_min = dur_sec / 60 + if dur_min >= window_min: + zone_info = classify_zone( + records[seg_start_idx].get('lat', 0), + records[seg_start_idx].get('lon', 0), + ) + segments.append({ + 'start_idx': seg_start_idx, + 'end_idx': i - 1, + 'duration_min': round(dur_min, 1), + 'zone': zone_info.get('zone', 'UNKNOWN'), + 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', + }) + in_fishing = False + + return segments + + +def detect_trawl_uturn(df_vessel: pd.DataFrame, + uturn_threshold_deg: float = 150.0, + min_uturn_count: int = 3) -> dict: + """U-turn 왕복 패턴 감지 (저인망 특징).""" + if len(df_vessel) < 2: + return {'uturn_count': 0, 'trawl_suspected': False} + + uturn_count = 0 + cog_vals = df_vessel['cog'].values + sog_vals = df_vessel['sog'].values + + for i in range(1, len(cog_vals)): + delta = abs((cog_vals[i] - cog_vals[i - 1] + 180) % 360 - 180) + if delta >= uturn_threshold_deg and sog_vals[i] < TRANSIT_SOG_MIN: + uturn_count += 1 + + return { + 'uturn_count': uturn_count, + 'trawl_suspected': uturn_count >= min_uturn_count, + } diff --git a/prediction/algorithms/fleet.py b/prediction/algorithms/fleet.py new file mode 100644 index 0000000..22ccd0c --- /dev/null +++ b/prediction/algorithms/fleet.py @@ -0,0 +1,152 @@ +import math +import logging + +import numpy as np +import pandas as pd +from algorithms.location import haversine_nm, dist_to_baseline, EARTH_RADIUS_NM + +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, +) -> 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: + continue + clusters.setdefault(int(label), []).append(vessel_snapshots[idx]) + + return clusters + + +def identify_lead_vessel(cluster_vessels: list[dict]) -> dict: + """5기준 스코어링으로 대표선 특정.""" + if not cluster_vessels: + return {} + + scores: dict[str, float] = {} + + timestamps = [pd.Timestamp(v.get('timestamp', 0)).timestamp() for v in cluster_vessels] + min_ts = min(timestamps) if timestamps else 0 + + 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)) + + for i, v in enumerate(cluster_vessels): + mmsi = v['mmsi'] + s = 0.0 + + # 기준 1: 최초 시각 (30점) + ts_rank = timestamps[i] - min_ts + s += 30.0 * (1.0 - min(ts_rank, 7200) / 7200) + + # 기준 2: 총톤수 (25점) — 외부 DB 연동 전까지 균등 배점 + s += 12.5 + + # 기준 3: 클러스터 중심 근접성 (20점) + dist_center = haversine_nm(v['lat'], v['lon'], centroid_lat, centroid_lon) + s += 20.0 * (1.0 - min(dist_center, 10) / 10) + + # 기준 4: 기선 최근접 (15점) + dist_base = dist_to_baseline(v['lat'], v['lon']) + s += 15.0 * (1.0 - min(dist_base, 12) / 12) + + # 기준 5: AIS 소실 이력 (10점) — 이력 없으면 만점 + s += 10.0 + + 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, + } + + +def assign_fleet_roles( + vessel_dfs: dict[str, pd.DataFrame], + cluster_map: dict[str, int], +) -> dict[str, dict]: + """선단 역할 할당: LEADER/MEMBER/NOISE.""" + results: dict[str, dict] = {} + + # 클러스터별 그룹핑 + clusters: dict[int, list[str]] = {} + for mmsi, cid in cluster_map.items(): + clusters.setdefault(cid, []).append(mmsi) + + 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: + results[mmsi] = { + 'cluster_size': cluster_size, + 'is_leader': mmsi == lead_mmsi, + 'fleet_role': 'LEADER' if mmsi == lead_mmsi else 'MEMBER', + } + + return results diff --git a/prediction/algorithms/location.py b/prediction/algorithms/location.py new file mode 100644 index 0000000..44ccf86 --- /dev/null +++ b/prediction/algorithms/location.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import List, Optional, Tuple + +EARTH_RADIUS_NM = 3440.065 +TERRITORIAL_SEA_NM = 12.0 +CONTIGUOUS_ZONE_NM = 24.0 + +_baseline_points: Optional[List[Tuple[float, float]]] = None + + +def _load_baseline() -> List[Tuple[float, float]]: + global _baseline_points + if _baseline_points is not None: + return _baseline_points + path = Path(__file__).parent.parent / 'data' / 'korea_baseline.json' + with open(path, 'r') as f: + data = json.load(f) + _baseline_points = [(p['lat'], p['lon']) for p in data['points']] + return _baseline_points + + +def haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """두 좌표 간 거리 (해리).""" + R = EARTH_RADIUS_NM + 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 dist_to_baseline(vessel_lat: float, vessel_lon: float, + baseline_points: Optional[List[Tuple[float, float]]] = None) -> float: + """선박 좌표에서 기선까지 최소 거리 (NM).""" + if baseline_points is None: + baseline_points = _load_baseline() + min_dist = float('inf') + for bp_lat, bp_lon in baseline_points: + d = haversine_nm(vessel_lat, vessel_lon, bp_lat, bp_lon) + if d < min_dist: + min_dist = d + return min_dist + + +def classify_zone(vessel_lat: float, vessel_lon: float) -> dict: + """선박 위치 수역 분류.""" + dist = dist_to_baseline(vessel_lat, vessel_lon) + + if dist <= TERRITORIAL_SEA_NM: + return { + 'zone': 'TERRITORIAL_SEA', + 'dist_from_baseline_nm': round(dist, 2), + 'violation': True, + 'alert_level': 'CRITICAL', + } + elif dist <= CONTIGUOUS_ZONE_NM: + return { + 'zone': 'CONTIGUOUS_ZONE', + 'dist_from_baseline_nm': round(dist, 2), + 'violation': False, + 'alert_level': 'WATCH', + } + else: + return { + 'zone': 'EEZ_OR_BEYOND', + 'dist_from_baseline_nm': round(dist, 2), + 'violation': False, + 'alert_level': 'NORMAL', + } + + +def bd09_to_wgs84(bd_lat: float, bd_lon: float) -> tuple[float, float]: + """BD-09 좌표계를 WGS84로 변환.""" + x = bd_lon - 0.0065 + y = bd_lat - 0.006 + z = math.sqrt(x ** 2 + y ** 2) - 0.00002 * math.sin(y * 52.35987756) + theta = math.atan2(y, x) - 0.000003 * math.cos(x * 52.35987756) + gcj_lon = z * math.cos(theta) + gcj_lat = z * math.sin(theta) + wgs_lat = gcj_lat - 0.0023 + wgs_lon = gcj_lon - 0.0059 + return wgs_lat, wgs_lon + + +def compute_bd09_offset(lat: float, lon: float) -> float: + """BD09 좌표와 WGS84 좌표 간 오프셋 (미터).""" + wgs_lat, wgs_lon = bd09_to_wgs84(lat, lon) + dist_nm = haversine_nm(lat, lon, wgs_lat, wgs_lon) + return round(dist_nm * 1852.0, 1) # NM to meters diff --git a/prediction/algorithms/risk.py b/prediction/algorithms/risk.py new file mode 100644 index 0000000..b11b3c0 --- /dev/null +++ b/prediction/algorithms/risk.py @@ -0,0 +1,75 @@ +from typing import Optional, Tuple + +import pandas as pd +from algorithms.location import classify_zone +from algorithms.fishing_pattern import detect_fishing_segments, detect_trawl_uturn +from algorithms.dark_vessel import detect_ais_gaps +from algorithms.spoofing import detect_teleportation + + +def compute_vessel_risk_score( + mmsi: str, + df_vessel: pd.DataFrame, + zone_info: Optional[dict] = None, + is_permitted: Optional[bool] = None, +) -> Tuple[int, str]: + """선박별 종합 위반 위험도 (0~100점). + + Returns: (risk_score, risk_level) + """ + if len(df_vessel) == 0: + return 0, 'LOW' + + score = 0 + + # 1. 위치 기반 (최대 40점) + if zone_info is None: + last = df_vessel.iloc[-1] + zone_info = classify_zone(last['lat'], last['lon']) + + zone = zone_info.get('zone', '') + if zone == 'TERRITORIAL_SEA': + score += 40 + elif zone == 'CONTIGUOUS_ZONE': + score += 10 + + # 2. 조업 행위 (최대 30점) + segs = detect_fishing_segments(df_vessel) + ts_fishing = [s for s in segs if s.get('in_territorial_sea')] + if ts_fishing: + score += 20 + elif segs: + score += 5 + + uturn = detect_trawl_uturn(df_vessel) + if uturn.get('trawl_suspected'): + score += 10 + + # 3. AIS 조작 (최대 35점) + teleports = detect_teleportation(df_vessel) + if teleports: + score += 20 + + gaps = detect_ais_gaps(df_vessel) + critical_gaps = [g for g in gaps if g['gap_min'] >= 60] + if critical_gaps: + score += 15 + elif gaps: + score += 5 + + # 4. 허가 이력 (최대 20점) + if is_permitted is not None and not is_permitted: + score += 20 + + score = min(score, 100) + + if score >= 70: + level = 'CRITICAL' + elif score >= 50: + level = 'HIGH' + elif score >= 30: + level = 'MEDIUM' + else: + level = 'LOW' + + return score, level diff --git a/prediction/algorithms/spoofing.py b/prediction/algorithms/spoofing.py new file mode 100644 index 0000000..e2ec081 --- /dev/null +++ b/prediction/algorithms/spoofing.py @@ -0,0 +1,80 @@ +import pandas as pd +from algorithms.location import haversine_nm, bd09_to_wgs84, compute_bd09_offset # noqa: F401 + +MAX_FISHING_SPEED_KNOTS = 25.0 + + +def detect_teleportation(df_vessel: pd.DataFrame, + max_speed_knots: float = MAX_FISHING_SPEED_KNOTS) -> list[dict]: + """연속 AIS 포인트 간 물리적 불가능 이동 탐지.""" + if len(df_vessel) < 2: + return [] + + anomalies = [] + records = df_vessel.sort_values('timestamp').to_dict('records') + + for i in range(1, len(records)): + prev, curr = records[i - 1], records[i] + dist_nm = haversine_nm(prev['lat'], prev['lon'], curr['lat'], curr['lon']) + dt_hours = ( + pd.Timestamp(curr['timestamp']) - pd.Timestamp(prev['timestamp']) + ).total_seconds() / 3600 + + if dt_hours <= 0: + continue + + implied_speed = dist_nm / dt_hours + + if implied_speed > max_speed_knots: + anomalies.append({ + 'idx': i, + 'dist_nm': round(dist_nm, 2), + 'implied_kn': round(implied_speed, 1), + 'type': 'TELEPORTATION', + 'confidence': 'HIGH' if implied_speed > 50 else 'MED', + }) + + return anomalies + + +def count_speed_jumps(df_vessel: pd.DataFrame, threshold_knots: float = 10.0) -> int: + """연속 SOG 급변 횟수.""" + if len(df_vessel) < 2: + return 0 + + sog = df_vessel['sog'].values + jumps = 0 + for i in range(1, len(sog)): + if abs(sog[i] - sog[i - 1]) > threshold_knots: + jumps += 1 + return jumps + + +def compute_spoofing_score(df_vessel: pd.DataFrame) -> float: + """종합 GPS 스푸핑 점수 (0~1).""" + if len(df_vessel) < 2: + return 0.0 + + score = 0.0 + n = len(df_vessel) + + # 순간이동 비율 + teleports = detect_teleportation(df_vessel) + if teleports: + score += min(0.4, len(teleports) / n * 10) + + # SOG 급변 비율 + jumps = count_speed_jumps(df_vessel) + if jumps > 0: + score += min(0.3, jumps / n * 5) + + # BD09 오프셋 (중국 좌표 사용 의심) + mid_idx = len(df_vessel) // 2 + row = df_vessel.iloc[mid_idx] + offset = compute_bd09_offset(row['lat'], row['lon']) + if offset > 300: # 300m 이상 + score += 0.3 + elif offset > 100: + score += 0.1 + + return round(min(score, 1.0), 4) diff --git a/prediction/cache/__init__.py b/prediction/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py new file mode 100644 index 0000000..8a59536 --- /dev/null +++ b/prediction/cache/vessel_store.py @@ -0,0 +1,335 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +import numpy as np +import pandas as pd + +logger = logging.getLogger(__name__) + +_STATIC_REFRESH_INTERVAL_MIN = 60 +_PERMIT_REFRESH_INTERVAL_MIN = 30 +_EARTH_RADIUS_NM = 3440.065 +_MAX_REASONABLE_SOG = 30.0 +_CHINESE_MMSI_PREFIX = '412' + + +def _compute_sog_cog(df: pd.DataFrame) -> pd.DataFrame: + """Compute SOG (knots) and COG (degrees) from consecutive lat/lon/timestamp points.""" + df = df.sort_values(['mmsi', 'timestamp']).copy() + + lat1 = np.radians(df['lat'].values[:-1]) + lon1 = np.radians(df['lon'].values[:-1]) + lat2 = np.radians(df['lat'].values[1:]) + lon2 = np.radians(df['lon'].values[1:]) + + # Haversine distance (nautical miles) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 + dist_nm = _EARTH_RADIUS_NM * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) + + # Time difference (hours) + ts = df['timestamp'].values + dt_sec = (ts[1:] - ts[:-1]).astype('timedelta64[s]').astype(float) + dt_hours = dt_sec / 3600.0 + dt_hours[dt_hours <= 0] = np.nan + + # SOG = dist / time (knots) + computed_sog = dist_nm / dt_hours + computed_sog = np.clip(np.nan_to_num(computed_sog, nan=0.0), 0, _MAX_REASONABLE_SOG) + + # COG = bearing (degrees) + x = np.sin(dlon) * np.cos(lat2) + y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon) + bearing = (np.degrees(np.arctan2(x, y)) + 360) % 360 + + # Append last value (copy from previous) + sog_arr = np.append(computed_sog, computed_sog[-1:] if len(computed_sog) > 0 else [0]) + cog_arr = np.append(bearing, bearing[-1:] if len(bearing) > 0 else [0]) + + # Reset at MMSI boundaries + mmsi_vals = df['mmsi'].values + boundary = np.where(mmsi_vals[:-1] != mmsi_vals[1:])[0] + for idx in boundary: + sog_arr[idx + 1] = df['raw_sog'].iloc[idx + 1] if 'raw_sog' in df.columns else 0 + cog_arr[idx + 1] = 0 + + # Where computed SOG is 0 or NaN, fall back to raw_sog + df['sog'] = sog_arr + if 'raw_sog' in df.columns: + mask = (df['sog'] == 0) | np.isnan(df['sog']) + df.loc[mask, 'sog'] = df.loc[mask, 'raw_sog'].fillna(0) + + df['cog'] = cog_arr + return df + + +class VesselStore: + """In-memory vessel trajectory store for Korean waters vessel data. + + Maintains a 24-hour sliding window of all vessel tracks and supports + incremental 5-minute updates. Chinese vessel (MMSI 412*) filtering + is applied only at analysis target selection time. + """ + + def __init__(self) -> None: + self._tracks: dict[str, pd.DataFrame] = {} + self._last_bucket: Optional[datetime] = None + self._static_info: dict[str, dict] = {} + self._permit_set: set[str] = set() + self._static_refreshed_at: Optional[datetime] = None + self._permit_refreshed_at: Optional[datetime] = None + + # ------------------------------------------------------------------ + # Public load / update methods + # ------------------------------------------------------------------ + + def load_initial(self, hours: int = 24) -> None: + """Load all Korean waters vessel data for the past N hours. + + Fetches a bulk DataFrame from snpdb, groups by MMSI, and stores + each vessel's track separately. Also triggers static info and + permit registry refresh. + """ + from db import snpdb + + logger.info('loading initial vessel tracks (last %dh)...', hours) + try: + df_all = snpdb.fetch_all_tracks(hours) + except Exception as e: + logger.error('fetch_all_tracks failed: %s', e) + return + + if df_all.empty: + logger.warning('fetch_all_tracks returned empty DataFrame') + return + + # Rename sog column to raw_sog to preserve original AIS-reported speed + if 'sog' in df_all.columns and 'raw_sog' not in df_all.columns: + df_all = df_all.rename(columns={'sog': 'raw_sog'}) + + self._tracks = {} + for mmsi, group in df_all.groupby('mmsi'): + self._tracks[str(mmsi)] = group.reset_index(drop=True) + + 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', + vessel_count, + point_count, + ) + + self.refresh_static_info() + self.refresh_permit_registry() + + def merge_incremental(self, df_new: pd.DataFrame) -> None: + """Merge a new batch of vessel positions into the in-memory store. + + Deduplicates by timestamp within each MMSI and updates _last_bucket. + """ + if df_new.empty: + logger.debug('merge_incremental called with empty DataFrame, skipping') + return + + if 'sog' in df_new.columns and 'raw_sog' not in df_new.columns: + df_new = df_new.rename(columns={'sog': 'raw_sog'}) + + new_buckets: list[datetime] = [] + + for mmsi, group in df_new.groupby('mmsi'): + mmsi_str = str(mmsi) + if mmsi_str in self._tracks: + combined = pd.concat([self._tracks[mmsi_str], group], ignore_index=True) + combined = combined.drop_duplicates(subset=['timestamp']) + self._tracks[mmsi_str] = combined.reset_index(drop=True) + else: + self._tracks[mmsi_str] = group.reset_index(drop=True) + + if 'time_bucket' in group.columns and not group['time_bucket'].empty: + bucket_vals = pd.to_datetime(group['time_bucket'].dropna()) + if not bucket_vals.empty: + new_buckets.append(bucket_vals.max().to_pydatetime()) + + if new_buckets: + latest = max(new_buckets) + if isinstance(latest, datetime) and latest.tzinfo is None: + latest = latest.replace(tzinfo=timezone.utc) + if self._last_bucket is None or latest > self._last_bucket: + self._last_bucket = latest + + logger.debug( + 'incremental merge done: %d mmsis in batch, store has %d vessels', + df_new['mmsi'].nunique(), + len(self._tracks), + ) + + def evict_stale(self, hours: int = 24) -> None: + """Remove track points older than N hours and evict empty MMSI entries.""" + import datetime as _dt + + now = datetime.now(timezone.utc) + cutoff_aware = now - _dt.timedelta(hours=hours) + cutoff_naive = cutoff_aware.replace(tzinfo=None) + + before_total = sum(len(v) for v in self._tracks.values()) + evicted_mmsis: list[str] = [] + + for mmsi in list(self._tracks.keys()): + df = self._tracks[mmsi] + ts_col = df['timestamp'] + # Handle tz-aware and tz-naive timestamps uniformly + if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None: + mask = ts_col >= pd.Timestamp(cutoff_aware) + else: + mask = ts_col >= pd.Timestamp(cutoff_naive) + filtered = df[mask].reset_index(drop=True) + if filtered.empty: + del self._tracks[mmsi] + evicted_mmsis.append(mmsi) + else: + self._tracks[mmsi] = filtered + + after_total = sum(len(v) for v in self._tracks.values()) + logger.info( + 'eviction complete: removed %d points, evicted %d mmsis (threshold=%dh)', + before_total - after_total, + len(evicted_mmsis), + hours, + ) + + def refresh_static_info(self) -> None: + """Fetch vessel static info (type, name, dimensions) from snpdb. + + Skips refresh if called within the last 60 minutes. + """ + now = datetime.now(timezone.utc) + if self._static_refreshed_at is not None: + elapsed_min = (now - self._static_refreshed_at).total_seconds() / 60 + if elapsed_min < _STATIC_REFRESH_INTERVAL_MIN: + logger.debug( + 'static info refresh skipped (%.1f min since last refresh)', + elapsed_min, + ) + return + + if not self._tracks: + logger.debug('no tracks in store, skipping static info refresh') + return + + from db import snpdb + + mmsi_list = list(self._tracks.keys()) + try: + info = snpdb.fetch_static_info(mmsi_list) + self._static_info.update(info) + self._static_refreshed_at = now + logger.info('static info refreshed: %d vessels', len(info)) + except Exception as e: + logger.error('fetch_static_info failed: %s', e) + + def refresh_permit_registry(self) -> None: + """Fetch permitted Chinese fishing vessel MMSIs from snpdb. + + Skips refresh if called within the last 30 minutes. + """ + now = datetime.now(timezone.utc) + if self._permit_refreshed_at is not None: + elapsed_min = (now - self._permit_refreshed_at).total_seconds() / 60 + if elapsed_min < _PERMIT_REFRESH_INTERVAL_MIN: + logger.debug( + 'permit registry refresh skipped (%.1f min since last refresh)', + elapsed_min, + ) + return + + from db import snpdb + + try: + mmsis = snpdb.fetch_permit_mmsis() + self._permit_set = set(mmsis) + self._permit_refreshed_at = now + logger.info('permit registry refreshed: %d permitted vessels', len(self._permit_set)) + except Exception as e: + logger.error('fetch_permit_mmsis failed: %s', e) + + # ------------------------------------------------------------------ + # Analysis target selection + # ------------------------------------------------------------------ + + def select_analysis_targets(self) -> pd.DataFrame: + """Build a combined DataFrame of Chinese vessel tracks with computed SOG/COG. + + Filters to MMSI starting with '412', computes SOG and COG from + consecutive lat/lon/timestamp pairs using the haversine formula, + and falls back to raw_sog where computed values are zero or NaN. + + Returns: + DataFrame with columns: mmsi, timestamp, lat, lon, sog, cog + """ + chinese_mmsis = [m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)] + if not chinese_mmsis: + logger.info('no Chinese vessels (412*) found in store') + return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'sog', 'cog']) + + frames = [self._tracks[m] for m in chinese_mmsis] + combined = pd.concat(frames, ignore_index=True) + + required_cols = {'mmsi', 'timestamp', 'lat', 'lon'} + missing = required_cols - set(combined.columns) + if missing: + logger.error('combined DataFrame missing required columns: %s', missing) + return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'sog', 'cog']) + + result = _compute_sog_cog(combined) + + output_cols = ['mmsi', 'timestamp', 'lat', 'lon', 'sog', 'cog'] + available = [c for c in output_cols if c in result.columns] + return result[available].reset_index(drop=True) + + # ------------------------------------------------------------------ + # Lookup helpers + # ------------------------------------------------------------------ + + def is_permitted(self, mmsi: str) -> bool: + """Return True if the given MMSI is in the permitted Chinese fishing vessel registry.""" + return mmsi in self._permit_set + + def get_vessel_info(self, mmsi: str) -> dict: + """Return static vessel info dict for the given MMSI, or empty dict if not found.""" + return self._static_info.get(mmsi, {}) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def last_bucket(self) -> Optional[datetime]: + """Return the latest time bucket seen across all merged incremental batches.""" + return self._last_bucket + + # ------------------------------------------------------------------ + # Diagnostics + # ------------------------------------------------------------------ + + def stats(self) -> dict: + """Return store statistics for health/status reporting.""" + total_points = sum(len(v) for v in self._tracks.values()) + chinese_count = sum(1 for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)) + + # Rough memory estimate: each row ~200 bytes across columns + memory_mb = round((total_points * 200) / (1024 * 1024), 2) + + return { + 'vessels': len(self._tracks), + 'points': total_points, + 'memory_mb': memory_mb, + 'last_bucket': self._last_bucket.isoformat() if self._last_bucket else None, + 'targets': chinese_count, + 'permitted': len(self._permit_set), + } + + +# Module-level singleton +vessel_store = VesselStore() diff --git a/prediction/config.py b/prediction/config.py new file mode 100644 index 0000000..7414022 --- /dev/null +++ b/prediction/config.py @@ -0,0 +1,40 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # snpdb (궤적 데이터 소스) + SNPDB_HOST: str = '211.208.115.83' + SNPDB_PORT: int = 5432 + SNPDB_NAME: str = 'snpdb' + SNPDB_USER: str = 'snp' + SNPDB_PASSWORD: str + + # kcgdb (분석 결과 저장) + KCGDB_HOST: str = '211.208.115.83' + KCGDB_PORT: int = 5432 + KCGDB_NAME: str = 'kcgdb' + KCGDB_SCHEMA: str = 'kcg' + KCGDB_USER: str = 'kcg_app' + KCGDB_PASSWORD: str + + # 스케줄러 + SCHEDULER_INTERVAL_MIN: int = 5 + + # 인메모리 캐시 + CACHE_WINDOW_HOURS: int = 24 + INITIAL_LOAD_HOURS: int = 24 + STATIC_INFO_REFRESH_MIN: int = 60 + PERMIT_REFRESH_MIN: int = 30 + + # 파이프라인 + TRAJECTORY_HOURS: int = 6 + MMSI_PREFIX: str = '412' + MIN_TRAJ_POINTS: int = 100 + + # 로깅 + LOG_LEVEL: str = 'INFO' + + model_config = {'env_file': '.env', 'env_file_encoding': 'utf-8', 'extra': 'ignore'} + + +settings = Settings() diff --git a/prediction/data/korea_baseline.json b/prediction/data/korea_baseline.json new file mode 100644 index 0000000..9b20cd7 --- /dev/null +++ b/prediction/data/korea_baseline.json @@ -0,0 +1 @@ +{"points": [{"lat": 37.0, "lon": 124.0}, {"lat": 35.0, "lon": 129.0}]} \ No newline at end of file diff --git a/prediction/db/__init__.py b/prediction/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py new file mode 100644 index 0000000..1014d3d --- /dev/null +++ b/prediction/db/kcgdb.py @@ -0,0 +1,134 @@ +import logging +from contextlib import contextmanager +from typing import TYPE_CHECKING, Optional + +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import execute_values + +from config import settings + +if TYPE_CHECKING: + from models.result import AnalysisResult + +logger = logging.getLogger(__name__) + +_pool: Optional[pool.ThreadedConnectionPool] = None + + +def init_pool(): + global _pool + _pool = pool.ThreadedConnectionPool( + minconn=1, + maxconn=3, + host=settings.KCGDB_HOST, + port=settings.KCGDB_PORT, + dbname=settings.KCGDB_NAME, + user=settings.KCGDB_USER, + password=settings.KCGDB_PASSWORD, + options=f'-c search_path={settings.KCGDB_SCHEMA},public', + ) + logger.info('kcgdb connection pool initialized') + + +def close_pool(): + global _pool + if _pool: + _pool.closeall() + _pool = None + logger.info('kcgdb connection pool closed') + + +@contextmanager +def get_conn(): + conn = _pool.getconn() + try: + yield conn + except Exception: + conn.rollback() + raise + finally: + _pool.putconn(conn) + + +def check_health() -> bool: + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT 1') + return True + except Exception as e: + logger.error('kcgdb health check failed: %s', e) + return False + + +def upsert_results(results: list['AnalysisResult']) -> int: + """분석 결과를 vessel_analysis_results 테이블에 upsert.""" + if not results: + return 0 + + insert_sql = """ + INSERT INTO vessel_analysis_results ( + mmsi, timestamp, vessel_type, confidence, fishing_pct, + cluster_id, season, zone, dist_to_baseline_nm, activity_state, + ucaf_score, ucft_score, is_dark, gap_duration_min, + spoofing_score, bd09_offset_m, speed_jump_count, + cluster_size, is_leader, fleet_role, + risk_score, risk_level, features, analyzed_at + ) VALUES %s + ON CONFLICT (mmsi, timestamp) DO UPDATE SET + vessel_type = EXCLUDED.vessel_type, + confidence = EXCLUDED.confidence, + fishing_pct = EXCLUDED.fishing_pct, + cluster_id = EXCLUDED.cluster_id, + season = EXCLUDED.season, + zone = EXCLUDED.zone, + dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm, + activity_state = EXCLUDED.activity_state, + ucaf_score = EXCLUDED.ucaf_score, + ucft_score = EXCLUDED.ucft_score, + is_dark = EXCLUDED.is_dark, + gap_duration_min = EXCLUDED.gap_duration_min, + spoofing_score = EXCLUDED.spoofing_score, + bd09_offset_m = EXCLUDED.bd09_offset_m, + speed_jump_count = EXCLUDED.speed_jump_count, + cluster_size = EXCLUDED.cluster_size, + is_leader = EXCLUDED.is_leader, + fleet_role = EXCLUDED.fleet_role, + risk_score = EXCLUDED.risk_score, + risk_level = EXCLUDED.risk_level, + features = EXCLUDED.features, + analyzed_at = EXCLUDED.analyzed_at + """ + + try: + with get_conn() as conn: + with conn.cursor() as cur: + tuples = [r.to_db_tuple() for r in results] + execute_values(cur, insert_sql, tuples, page_size=100) + conn.commit() + count = len(tuples) + logger.info('upserted %d analysis results', count) + return count + except Exception as e: + logger.error('failed to upsert results: %s', e) + return 0 + + +def cleanup_old(hours: int = 48) -> int: + """오래된 분석 결과 삭제.""" + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + 'DELETE FROM vessel_analysis_results WHERE analyzed_at < NOW() - (%s * INTERVAL \'1 hour\')', + (hours,), + ) + deleted = cur.rowcount + conn.commit() + if deleted > 0: + logger.info('cleaned up %d old results (older than %dh)', deleted, hours) + return deleted + except Exception as e: + logger.error('failed to cleanup old results: %s', e) + return 0 diff --git a/prediction/db/snpdb.py b/prediction/db/snpdb.py new file mode 100644 index 0000000..fda2397 --- /dev/null +++ b/prediction/db/snpdb.py @@ -0,0 +1,187 @@ +import logging +from contextlib import contextmanager +from datetime import datetime +from typing import Optional + +import pandas as pd +import psycopg2 +from psycopg2 import pool + +from config import settings + +logger = logging.getLogger(__name__) + +_pool: Optional[pool.ThreadedConnectionPool] = None + + +def init_pool(): + global _pool + _pool = pool.ThreadedConnectionPool( + minconn=1, + maxconn=3, + host=settings.SNPDB_HOST, + port=settings.SNPDB_PORT, + dbname=settings.SNPDB_NAME, + user=settings.SNPDB_USER, + password=settings.SNPDB_PASSWORD, + ) + logger.info('snpdb connection pool initialized') + + +def close_pool(): + global _pool + if _pool: + _pool.closeall() + _pool = None + logger.info('snpdb connection pool closed') + + +@contextmanager +def get_conn(): + conn = _pool.getconn() + try: + yield conn + finally: + _pool.putconn(conn) + + +def check_health() -> bool: + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT 1') + return True + except Exception as e: + logger.error('snpdb health check failed: %s', e) + return False + + +def fetch_all_tracks(hours: int = 24) -> pd.DataFrame: + """한국 해역 전 선박의 궤적 포인트를 조회한다. + + LineStringM 지오메트리에서 개별 포인트를 추출하며, + 한국 해역(124-132E, 32-39N) 내 최근 N시간 데이터를 반환한다. + """ + query = f""" + SELECT + t.mmsi, + to_timestamp(ST_M((dp).geom)) as timestamp, + ST_Y((dp).geom) as lat, + ST_X((dp).geom) as lon, + CASE + WHEN (dp).path[1] = 1 THEN (t.start_position->>'sog')::float + ELSE COALESCE((t.end_position->>'sog')::float, t.avg_speed::float) + END as raw_sog + FROM signal.t_vessel_tracks_5min t, + LATERAL ST_DumpPoints(t.track_geom) dp + WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours' + AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) + ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) + """ + + try: + with get_conn() as conn: + df = pd.read_sql_query(query, conn) + logger.info( + 'fetch_all_tracks: %d rows, %d vessels (last %dh)', + len(df), + df['mmsi'].nunique() if len(df) > 0 else 0, + hours, + ) + return df + except Exception as e: + logger.error('fetch_all_tracks failed: %s', e) + return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'raw_sog']) + + +def fetch_incremental(last_bucket: datetime) -> pd.DataFrame: + """last_bucket 이후의 신규 궤적 포인트를 조회한다. + + 스케줄러 증분 업데이트에 사용되며, time_bucket > last_bucket 조건으로 + 이미 처리한 버킷을 건너뛴다. + """ + query = """ + SELECT + t.mmsi, + to_timestamp(ST_M((dp).geom)) as timestamp, + ST_Y((dp).geom) as lat, + ST_X((dp).geom) as lon, + CASE + WHEN (dp).path[1] = 1 THEN (t.start_position->>'sog')::float + ELSE COALESCE((t.end_position->>'sog')::float, t.avg_speed::float) + END as raw_sog + FROM signal.t_vessel_tracks_5min t, + LATERAL ST_DumpPoints(t.track_geom) dp + WHERE t.time_bucket > %s + AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) + ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) + """ + + try: + with get_conn() as conn: + df = pd.read_sql_query(query, conn, params=(last_bucket,)) + logger.info( + 'fetch_incremental: %d rows, %d vessels (since %s)', + len(df), + df['mmsi'].nunique() if len(df) > 0 else 0, + last_bucket.isoformat(), + ) + return df + except Exception as e: + logger.error('fetch_incremental failed: %s', e) + return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'raw_sog']) + + +def fetch_static_info(mmsi_list: list[str]) -> dict[str, dict]: + """MMSI 목록에 해당하는 선박 정적 정보를 조회한다. + + DISTINCT ON (mmsi)로 최신 레코드만 반환한다. + """ + query = """ + SELECT DISTINCT ON (mmsi) mmsi, name, vessel_type, length, width + FROM signal.t_vessel_static + WHERE mmsi = ANY(%s) + ORDER BY mmsi, time_bucket DESC + """ + + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(query, (mmsi_list,)) + rows = cur.fetchall() + result = { + row[0]: { + 'name': row[1], + 'vessel_type': row[2], + 'length': row[3], + 'width': row[4], + } + for row in rows + } + logger.info('fetch_static_info: %d vessels resolved', len(result)) + return result + except Exception as e: + logger.error('fetch_static_info failed: %s', e) + return {} + + +def fetch_permit_mmsis() -> set[str]: + """중국 허가어선 MMSI 목록을 조회한다. + + signal.t_chnprmship_positions 테이블에서 DISTINCT mmsi를 반환한다. + """ + query = """ + SELECT DISTINCT mmsi FROM signal.t_chnprmship_positions + """ + + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(query) + rows = cur.fetchall() + result = {row[0] for row in rows} + logger.info('fetch_permit_mmsis: %d permitted vessels', len(result)) + return result + except Exception as e: + logger.error('fetch_permit_mmsis failed: %s', e) + return set() diff --git a/prediction/env.example b/prediction/env.example new file mode 100644 index 0000000..c771eac --- /dev/null +++ b/prediction/env.example @@ -0,0 +1,25 @@ +# snpdb (궤적 데이터 소스) +SNPDB_HOST=211.208.115.83 +SNPDB_PORT=5432 +SNPDB_NAME=snpdb +SNPDB_USER=snp +SNPDB_PASSWORD=snp#8932 + +# kcgdb (분석 결과 저장) +KCGDB_HOST=211.208.115.83 +KCGDB_PORT=5432 +KCGDB_NAME=kcgdb +KCGDB_SCHEMA=kcg +KCGDB_USER=kcg_app +KCGDB_PASSWORD=Kcg2026monitor + +# 스케줄러 +SCHEDULER_INTERVAL_MIN=5 + +# 파이프라인 +TRAJECTORY_HOURS=6 +MMSI_PREFIX=412 +MIN_TRAJ_POINTS=100 + +# 로깅 +LOG_LEVEL=INFO diff --git a/prediction/main.py b/prediction/main.py index fbccfe3..4f717dd 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -1,8 +1,66 @@ -from fastapi import FastAPI +import logging +import sys +from contextlib import asynccontextmanager -app = FastAPI(title="KCG Prediction Service", version="0.1.0") +from fastapi import BackgroundTasks, FastAPI + +from config import settings +from db import kcgdb, snpdb +from scheduler import get_last_run, run_analysis_cycle, start_scheduler, stop_scheduler + +logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL, logging.INFO), + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) -@app.get("/health") +@asynccontextmanager +async def lifespan(application: FastAPI): + from cache.vessel_store import vessel_store + + logger.info('starting KCG Prediction Service') + snpdb.init_pool() + kcgdb.init_pool() + + # 인메모리 캐시 초기 로드 (24시간) + logger.info('loading initial vessel data (%dh)...', settings.INITIAL_LOAD_HOURS) + vessel_store.load_initial(settings.INITIAL_LOAD_HOURS) + logger.info('initial load complete: %s', vessel_store.stats()) + + start_scheduler() + yield + stop_scheduler() + snpdb.close_pool() + kcgdb.close_pool() + logger.info('KCG Prediction Service stopped') + + +app = FastAPI( + title='KCG Prediction Service', + version='2.0.0', + lifespan=lifespan, +) + + +@app.get('/health') def health_check(): - return {"status": "ok"} + from cache.vessel_store import vessel_store + return { + 'status': 'ok', + 'snpdb': snpdb.check_health(), + 'kcgdb': kcgdb.check_health(), + 'store': vessel_store.stats(), + } + + +@app.get('/api/v1/analysis/status') +def analysis_status(): + return get_last_run() + + +@app.post('/api/v1/analysis/trigger') +def trigger_analysis(background_tasks: BackgroundTasks): + background_tasks.add_task(run_analysis_cycle) + return {'message': 'analysis cycle triggered'} diff --git a/prediction/models/__init__.py b/prediction/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prediction/models/ais.py b/prediction/models/ais.py new file mode 100644 index 0000000..9effbc4 --- /dev/null +++ b/prediction/models/ais.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import List, Dict + +import pandas as pd + + +@dataclass +class AISPoint: + mmsi: str + ts: pd.Timestamp + lat: float + lon: float + sog: float + cog: float + state: str = 'UNKNOWN' + + +@dataclass +class VesselTrajectory: + mmsi: str + points: List[AISPoint] = field(default_factory=list) + vessel_type: str = 'UNKNOWN' + cluster_id: int = -1 + season: str = 'UNKNOWN' + fishing_pct: float = 0.0 + features: Dict = field(default_factory=dict) + + +@dataclass +class ClassificationResult: + mmsi: str + vessel_type: str + confidence: float + dominant_state: str + fishing_pct: float + cluster_id: int + season: str + feature_vector: Dict diff --git a/prediction/models/result.py b/prediction/models/result.py new file mode 100644 index 0000000..9792351 --- /dev/null +++ b/prediction/models/result.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + + +@dataclass +class AnalysisResult: + """vessel_analysis_results 테이블 28컬럼 매핑.""" + + mmsi: str + timestamp: datetime + + # 분류 결과 + vessel_type: str = 'UNKNOWN' + confidence: float = 0.0 + fishing_pct: float = 0.0 + cluster_id: int = -1 + season: str = 'UNKNOWN' + + # ALGO 01: 위치 + zone: str = 'EEZ_OR_BEYOND' + dist_to_baseline_nm: float = 999.0 + + # ALGO 02: 활동 상태 + activity_state: str = 'UNKNOWN' + ucaf_score: float = 0.0 + ucft_score: float = 0.0 + + # ALGO 03: 다크 베셀 + is_dark: bool = False + gap_duration_min: int = 0 + + # ALGO 04: GPS 스푸핑 + spoofing_score: float = 0.0 + bd09_offset_m: float = 0.0 + speed_jump_count: int = 0 + + # ALGO 05+06: 선단 + cluster_size: int = 0 + is_leader: bool = False + fleet_role: str = 'NOISE' + + # ALGO 07: 위험도 + risk_score: int = 0 + risk_level: str = 'LOW' + + # 특징 벡터 + features: dict = field(default_factory=dict) + + # 메타 + analyzed_at: Optional[datetime] = None + + def __post_init__(self): + if self.analyzed_at is None: + self.analyzed_at = datetime.now(timezone.utc) + + def to_db_tuple(self) -> tuple: + import json + return ( + 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), + self.analyzed_at, + ) diff --git a/prediction/pipeline/__init__.py b/prediction/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prediction/pipeline/behavior.py b/prediction/pipeline/behavior.py new file mode 100644 index 0000000..7d40a83 --- /dev/null +++ b/prediction/pipeline/behavior.py @@ -0,0 +1,31 @@ +import pandas as pd +from pipeline.constants import SOG_STATIONARY_MAX, SOG_FISHING_MAX + + +class BehaviorDetector: + """ + 속도 기반 3단계 행동 분류 (Yan et al. 2022, Natale et al. 2015) + 정박(STATIONARY) / 조업(FISHING) / 항행(SAILING) + """ + + @staticmethod + def classify_point(sog: float) -> str: + if sog < SOG_STATIONARY_MAX: + return 'STATIONARY' + elif sog <= SOG_FISHING_MAX: + return 'FISHING' + else: + return 'SAILING' + + def detect(self, df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df['state'] = df['sog'].apply(self.classify_point) + return df + + @staticmethod + def compute_fishing_ratio(df_vessel: pd.DataFrame) -> float: + total = len(df_vessel) + if total == 0: + return 0.0 + fishing = (df_vessel['state'] == 'FISHING').sum() + return round(fishing / total * 100, 2) diff --git a/prediction/pipeline/classifier.py b/prediction/pipeline/classifier.py new file mode 100644 index 0000000..9de9184 --- /dev/null +++ b/prediction/pipeline/classifier.py @@ -0,0 +1,100 @@ +import pandas as pd +from typing import Dict, Tuple + + +class VesselTypeClassifier: + """ + Rule-based scoring classifier for fishing vessel types. + + Scoring: for each feature in a type's profile, if the value falls within + the defined range a distance-based score is added (closer to the range + centre = higher score). Values outside the range incur a penalty. + Returns (vessel_type, confidence). + + TRAWL — trawling speed 2.5–4.5 kt, high COG variation + PURSE — purse-seine speed 3–5 kt, circular COG pattern + LONGLINE — longline speed 0.5–2 kt, low COG variation, long fishing runs + TRAP — trap/pot speed ~0 kt, many stationary events, short range + """ + + PROFILES: Dict[str, Dict[str, Tuple[float, float]]] = { + 'TRAWL': { + 'sog_fishing_mean': (2.5, 4.5), + 'cog_change_mean': (0.15, 9.9), + 'fishing_pct': (0.3, 0.7), + 'fishing_run_mean': (5, 50), + 'stationary_events': (0, 5), + }, + 'PURSE': { + 'sog_fishing_mean': (3.0, 5.0), + 'cog_circularity': (0.2, 1.0), + 'fishing_pct': (0.1, 0.5), + 'fishing_run_mean': (3, 30), + 'stationary_events': (0, 3), + }, + 'LONGLINE': { + 'sog_fishing_mean': (0.5, 2.5), + 'cog_change_mean': (0.0, 0.15), + 'fishing_pct': (0.4, 0.9), + 'fishing_run_mean': (20, 999), + 'stationary_events': (0, 10), + }, + 'TRAP': { + 'sog_fishing_mean': (0.0, 2.0), + 'stationary_pct': (0.2, 0.8), + 'stationary_events': (5, 999), + 'fishing_run_mean': (1, 10), + 'total_distance_km': (0, 100), + }, + } + + def classify(self, features: Dict) -> Tuple[str, float]: + """Classify a vessel from its feature dict. + + Returns: + (vessel_type, confidence) where confidence is in [0, 1]. + """ + if not features: + return 'UNKNOWN', 0.0 + + scores: Dict[str, float] = {} + for vtype, profile in self.PROFILES.items(): + score = 0.0 + matched = 0 + for feat_name, (lo, hi) in profile.items(): + val = features.get(feat_name) + if val is None: + continue + matched += 1 + if lo <= val <= hi: + mid = (lo + hi) / 2 + span = (hi - lo) / 2 if (hi - lo) > 0 else 1 + score += max(0.0, 1 - abs(val - mid) / span) + else: + overshoot = min(abs(val - lo), abs(val - hi)) + score -= min(0.5, overshoot / (hi - lo + 1e-9)) + scores[vtype] = score / matched if matched > 0 else 0.0 + + best_type = max(scores, key=lambda k: scores[k]) + total = sum(max(v, 0.0) for v in scores.values()) + confidence = scores[best_type] / total if total > 0 else 0.0 + + return best_type, round(confidence, 3) + + +def get_season(ts: pd.Timestamp) -> str: + """Return the Northern-Hemisphere season for a timestamp. + + Reference: paper 12 seasonal activity analysis (Chinese EEZ). + Chinese fishing ban period: Yellow Sea / East China Sea May–Sep, + South China Sea May–Aug. + """ + m = ts.month + if m in [3, 4, 5]: + return 'SPRING' + elif m in [6, 7, 8]: + return 'SUMMER' + elif m in [9, 10, 11]: + return 'FALL' + else: + return 'WINTER' diff --git a/prediction/pipeline/clusterer.py b/prediction/pipeline/clusterer.py new file mode 100644 index 0000000..7f5d34d --- /dev/null +++ b/prediction/pipeline/clusterer.py @@ -0,0 +1,101 @@ +from collections import Counter +from typing import Dict, Optional + +import numpy as np +import pandas as pd + +from pipeline.constants import BIRCH_THRESHOLD, BIRCH_BRANCHING, MIN_CLUSTER_SIZE + + +class EnhancedBIRCHClusterer: + """Trajectory clustering using sklearn Birch with a simple K-means fallback. + + Based on the enhanced-BIRCH approach (Yan, Yang et al.): + 1. Resample each trajectory to a fixed-length vector. + 2. Build a BIRCH CF-tree for memory-efficient hierarchical clustering. + 3. Small clusters (< MIN_CLUSTER_SIZE) are relabelled as noise (-1). + """ + + def __init__( + self, + threshold: float = BIRCH_THRESHOLD, + branching: int = BIRCH_BRANCHING, + n_clusters: Optional[int] = None, + ) -> None: + self.threshold = threshold + self.branching = branching + self.n_clusters = n_clusters + self._model = None + + def _traj_to_vector(self, df_vessel: pd.DataFrame, n_points: int = 20) -> np.ndarray: + """Convert a vessel trajectory DataFrame to a fixed-length vector. + + Linearly samples n_points from the trajectory and interleaves lat/lon + values, then normalises to zero mean / unit variance. + """ + lats = df_vessel['lat'].values + lons = df_vessel['lon'].values + idx = np.linspace(0, len(lats) - 1, n_points).astype(int) + vec = np.concatenate([lats[idx], lons[idx]]) + vec = (vec - vec.mean()) / (vec.std() + 1e-9) + return vec + + def fit_predict(self, vessels: Dict[str, pd.DataFrame]) -> Dict[str, int]: + """Cluster vessel trajectories. + + Args: + vessels: mapping of mmsi -> resampled trajectory DataFrame. + + Returns: + Mapping of mmsi -> cluster_id. Vessels in small clusters are + assigned cluster_id -1 (noise). Vessels with fewer than 20 + points are excluded from the result. + """ + mmsi_list: list[str] = [] + vectors: list[np.ndarray] = [] + + for mmsi, df_v in vessels.items(): + if len(df_v) < 20: + continue + mmsi_list.append(mmsi) + vectors.append(self._traj_to_vector(df_v)) + + if len(vectors) < 3: + return {m: 0 for m in mmsi_list} + + X = np.array(vectors) + + try: + from sklearn.cluster import Birch + model = Birch( + threshold=self.threshold, + branching_factor=self.branching, + n_clusters=self.n_clusters, + ) + labels = model.fit_predict(X) + self._model = model + except ImportError: + labels = self._simple_cluster(X) + + cnt = Counter(labels) + labels = np.array([lbl if cnt[lbl] >= MIN_CLUSTER_SIZE else -1 for lbl in labels]) + + return dict(zip(mmsi_list, labels.tolist())) + + @staticmethod + def _simple_cluster(X: np.ndarray, k: int = 5) -> np.ndarray: + """Fallback K-means used when sklearn is unavailable.""" + n = len(X) + k = min(k, n) + centers = X[np.random.choice(n, k, replace=False)] + labels = np.zeros(n, dtype=int) + for _ in range(20): + dists = np.array([[np.linalg.norm(x - c) for c in centers] for x in X]) + labels = dists.argmin(axis=1) + new_centers = np.array( + [X[labels == i].mean(axis=0) if (labels == i).any() else centers[i] for i in range(k)] + ) + if np.allclose(centers, new_centers, atol=1e-6): + break + centers = new_centers + return labels diff --git a/prediction/pipeline/constants.py b/prediction/pipeline/constants.py new file mode 100644 index 0000000..4f07866 --- /dev/null +++ b/prediction/pipeline/constants.py @@ -0,0 +1,26 @@ +SOG_STATIONARY_MAX = 1.0 +SOG_FISHING_MAX = 5.0 +SOG_SAILING_MIN = 5.0 + +VESSEL_SOG_PROFILE = { + 'TRAWL': {'min': 1.5, 'max': 4.5, 'mean': 2.8, 'cog_var': 'high'}, + 'PURSE': {'min': 2.0, 'max': 5.0, 'mean': 3.5, 'cog_var': 'circular'}, + 'LONGLINE': {'min': 0.5, 'max': 3.0, 'mean': 1.8, 'cog_var': 'low'}, + 'TRAP': {'min': 0.0, 'max': 2.0, 'mean': 0.8, 'cog_var': 'very_low'}, +} + +RESAMPLE_INTERVAL_MIN = 4 + +BIRCH_THRESHOLD = 0.35 +BIRCH_BRANCHING = 50 +MIN_CLUSTER_SIZE = 5 + +MMSI_DIGITS = 9 +MAX_VESSEL_LENGTH = 300 +MAX_SOG_KNOTS = 30.0 +MIN_TRAJ_POINTS = 100 + +KR_BOUNDS = { + 'lat_min': 32.0, 'lat_max': 39.0, + 'lon_min': 124.0, 'lon_max': 132.0, +} diff --git a/prediction/pipeline/features.py b/prediction/pipeline/features.py new file mode 100644 index 0000000..b59565e --- /dev/null +++ b/prediction/pipeline/features.py @@ -0,0 +1,93 @@ +import math +import numpy as np +import pandas as pd +from typing import Dict + + +class FeatureExtractor: + """ + 어선 유형 분류를 위한 특징 벡터 추출 + 논문 12 (남중국해 어선 유형 식별) 기반 핵심 피처: + - 속도 통계 (mean, std, 분위수) + - 침로 변동성 (COG variance → 선회 패턴) + - 조업 비율 및 조업 지속 시간 + - 이동 거리 및 해역 커버리지 + - 정박 빈도 (투망/양망 간격 추정) + """ + + @staticmethod + def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """두 좌표 간 거리 (km)""" + R = 6371.0 + 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 extract(self, df_vessel: pd.DataFrame) -> Dict[str, float]: + if len(df_vessel) < 10: + return {} + + sog = df_vessel['sog'].values + cog = df_vessel['cog'].values + states = df_vessel['state'].values + + # Speed features + fishing_sog = sog[states == 'FISHING'] if (states == 'FISHING').any() else np.array([0]) + feat: Dict[str, float] = { + 'sog_mean': float(np.mean(sog)), + 'sog_std': float(np.std(sog)), + 'sog_fishing_mean': float(np.mean(fishing_sog)), + 'sog_fishing_std': float(np.std(fishing_sog)), + 'sog_q25': float(np.percentile(sog, 25)), + 'sog_q75': float(np.percentile(sog, 75)), + } + + # COG features (선망: 원형, 트롤: 직선왕복, 연승: 부드러운 곡선) + cog_diff = np.abs(np.diff(np.unwrap(np.radians(cog)))) + feat['cog_change_mean'] = float(np.mean(cog_diff)) + feat['cog_change_std'] = float(np.std(cog_diff)) + feat['cog_circularity'] = float(np.sum(cog_diff > np.pi / 4) / len(cog_diff)) + + # State ratios + n = len(states) + feat['fishing_pct'] = float((states == 'FISHING').sum() / n) + feat['stationary_pct'] = float((states == 'STATIONARY').sum() / n) + feat['sailing_pct'] = float((states == 'SAILING').sum() / n) + + # Stationary events (투망·양망 횟수 추정) + stationary_events = 0 + prev = None + for s in states: + if s == 'STATIONARY' and prev != 'STATIONARY': + stationary_events += 1 + prev = s + feat['stationary_events'] = float(stationary_events) + + # Total distance (km) + lats = df_vessel['lat'].values + lons = df_vessel['lon'].values + total_dist = sum( + self.haversine(lats[i], lons[i], lats[i + 1], lons[i + 1]) + for i in range(len(lats) - 1) + ) + feat['total_distance_km'] = round(total_dist, 2) + + # Coverage (바운딩 박스 면적 — 근사) + feat['coverage_deg2'] = round(float(np.ptp(lats)) * float(np.ptp(lons)), 4) + + # Average fishing run length + fishing_runs = [] + run = 0 + for s in states: + if s == 'FISHING': + run += 1 + elif run > 0: + fishing_runs.append(run) + run = 0 + if run > 0: + fishing_runs.append(run) + feat['fishing_run_mean'] = float(np.mean(fishing_runs)) if fishing_runs else 0.0 + + return feat diff --git a/prediction/pipeline/orchestrator.py b/prediction/pipeline/orchestrator.py new file mode 100644 index 0000000..2bcbf86 --- /dev/null +++ b/prediction/pipeline/orchestrator.py @@ -0,0 +1,95 @@ +import logging + +import pandas as pd + +from pipeline.preprocessor import AISPreprocessor +from pipeline.behavior import BehaviorDetector +from pipeline.resampler import TrajectoryResampler +from pipeline.features import FeatureExtractor +from pipeline.classifier import VesselTypeClassifier, get_season +from pipeline.clusterer import EnhancedBIRCHClusterer +from pipeline.constants import RESAMPLE_INTERVAL_MIN + +logger = logging.getLogger(__name__) + + +class ChineseFishingVesselPipeline: + """7-step pipeline for classifying Chinese fishing vessel activity types. + + Steps: + 1. AIS preprocessing (Yan et al. 2022) + 2. Behaviour-state detection (speed-based 3-class) + 3. Trajectory resampling (Yan, Yang et al. — 4-minute interval) + 4. Feature vector extraction (paper 12) + 5. Vessel-type classification (rule-based scoring) + 6. Enhanced BIRCH trajectory clustering (Yan, Yang et al.) + 7. Seasonal activity tagging (paper 12) + """ + + def __init__(self) -> None: + self.preprocessor = AISPreprocessor() + self.detector = BehaviorDetector() + self.resampler = TrajectoryResampler(RESAMPLE_INTERVAL_MIN) + self.extractor = FeatureExtractor() + self.classifier = VesselTypeClassifier() + self.clusterer = EnhancedBIRCHClusterer() + + def run( + self, df_raw: pd.DataFrame + ) -> tuple[list[dict], dict[str, pd.DataFrame]]: + """Run the 7-step pipeline. + + Args: + df_raw: raw AIS DataFrame with columns mmsi, timestamp, lat, lon, + sog, cog. + + Returns: + (results, vessel_dfs) where: + - results is a list of classification dicts, each containing: + mmsi, vessel_type, confidence, fishing_pct, cluster_id, season, + n_points, features. + - vessel_dfs is a mapping of mmsi -> resampled trajectory DataFrame. + """ + # Step 1: preprocess + df = self.preprocessor.run(df_raw) + if len(df) == 0: + logger.warning('pipeline: no rows after preprocessing') + return [], {} + + # Step 2: behaviour detection + df = self.detector.detect(df) + + # Steps 3–5: per-vessel processing + vessel_dfs: dict[str, pd.DataFrame] = {} + results: list[dict] = [] + + for mmsi, df_v in df.groupby('mmsi'): + df_resampled = self.resampler.resample(df_v) + vessel_dfs[mmsi] = df_resampled + + features = self.extractor.extract(df_resampled) + vtype, confidence = self.classifier.classify(features) + fishing_pct = BehaviorDetector.compute_fishing_ratio(df_resampled) + season = get_season(df_v['timestamp'].iloc[len(df_v) // 2]) + + results.append({ + 'mmsi': mmsi, + 'vessel_type': vtype, + 'confidence': confidence, + 'fishing_pct': fishing_pct, + 'season': season, + 'n_points': len(df_resampled), + 'features': features, + }) + + # Step 6: BIRCH clustering + cluster_map = self.clusterer.fit_predict(vessel_dfs) + for r in results: + r['cluster_id'] = cluster_map.get(r['mmsi'], -1) + + logger.info( + 'pipeline complete: %d vessels, types=%s', + len(results), + {r['vessel_type'] for r in results}, + ) + return results, vessel_dfs diff --git a/prediction/pipeline/preprocessor.py b/prediction/pipeline/preprocessor.py new file mode 100644 index 0000000..762d651 --- /dev/null +++ b/prediction/pipeline/preprocessor.py @@ -0,0 +1,52 @@ +import pandas as pd +from collections import defaultdict + +from pipeline.constants import KR_BOUNDS, MAX_SOG_KNOTS, MIN_TRAJ_POINTS + + +class AISPreprocessor: + """Delete-Supplement-Update (Yan et al. 2022)""" + + def __init__(self): + self.stats = defaultdict(int) + + def run(self, df: pd.DataFrame) -> pd.DataFrame: + original = len(df) + + required = ['mmsi', 'timestamp', 'lat', 'lon', 'sog', 'cog'] + missing = [c for c in required if c not in df.columns] + if missing: + raise ValueError(f"필수 컬럼 누락: {missing}") + + df = df.copy() + df['timestamp'] = pd.to_datetime(df['timestamp']) + + valid_mmsi = df['mmsi'].astype(str).str.match(r'^\d{9}$') + df = df[valid_mmsi] + self.stats['invalid_mmsi'] += original - len(df) + + df = df[(df['lat'].between(-90, 90)) & (df['lon'].between(-180, 180))] + + df = df[ + df['lat'].between(KR_BOUNDS['lat_min'], KR_BOUNDS['lat_max']) & + df['lon'].between(KR_BOUNDS['lon_min'], KR_BOUNDS['lon_max']) + ] + + df = df.sort_values(['mmsi', 'timestamp']) + df['sog'] = df.groupby('mmsi')['sog'].transform( + lambda x: x.where( + x.between(0, MAX_SOG_KNOTS), + x.rolling(3, center=True, min_periods=1).mean(), + ) + ) + df = df[(df['sog'] >= 0) & (df['sog'] <= MAX_SOG_KNOTS)] + + counts = df.groupby('mmsi').size() + valid_mmsi_list = counts[counts >= MIN_TRAJ_POINTS].index + df = df[df['mmsi'].isin(valid_mmsi_list)] + + df = df.drop_duplicates(subset=['mmsi', 'timestamp']) + + self.stats['final_records'] = len(df) + self.stats['retention_pct'] = round(len(df) / max(original, 1) * 100, 2) + return df.reset_index(drop=True) diff --git a/prediction/pipeline/resampler.py b/prediction/pipeline/resampler.py new file mode 100644 index 0000000..2c6330f --- /dev/null +++ b/prediction/pipeline/resampler.py @@ -0,0 +1,35 @@ +import pandas as pd +from pipeline.constants import RESAMPLE_INTERVAL_MIN +from pipeline.behavior import BehaviorDetector + + +class TrajectoryResampler: + """ + 불균등 AIS 수신 간격을 균등 시간 간격으로 보간 + 목적: BIRCH 군집화의 입력 벡터 정규화 + 방법: 선형 보간 (위도·경도·SOG·COG) + 기준: 4분 간격 (Shepperson et al. 2017) + """ + + def __init__(self, interval_min: int = RESAMPLE_INTERVAL_MIN): + self.interval = pd.Timedelta(minutes=interval_min) + + def resample(self, df_vessel: pd.DataFrame) -> pd.DataFrame: + df_vessel = df_vessel.sort_values('timestamp').copy() + if len(df_vessel) < 2: + return df_vessel + + t_start = df_vessel['timestamp'].iloc[0] + t_end = df_vessel['timestamp'].iloc[-1] + new_times = pd.date_range(t_start, t_end, freq=self.interval) + + df_vessel = df_vessel.set_index('timestamp') + df_vessel = df_vessel.reindex(df_vessel.index.union(new_times)) + for col in ['lat', 'lon', 'sog', 'cog']: + if col in df_vessel.columns: + df_vessel[col] = df_vessel[col].interpolate(method='time') + + df_vessel = df_vessel.loc[new_times].reset_index() + df_vessel.rename(columns={'index': 'timestamp'}, inplace=True) + df_vessel['state'] = df_vessel['sog'].apply(BehaviorDetector.classify_point) + return df_vessel diff --git a/prediction/requirements.txt b/prediction/requirements.txt index 531efda..7268415 100644 --- a/prediction/requirements.txt +++ b/prediction/requirements.txt @@ -1,2 +1,8 @@ fastapi==0.115.0 uvicorn==0.30.6 +pydantic-settings>=2.0 +psycopg2-binary>=2.9 +numpy>=1.26 +pandas>=2.2 +scikit-learn>=1.5 +apscheduler>=3.10 diff --git a/prediction/scheduler.py b/prediction/scheduler.py new file mode 100644 index 0000000..1b6f755 --- /dev/null +++ b/prediction/scheduler.py @@ -0,0 +1,177 @@ +import logging +import time +from datetime import datetime, timezone +from typing import Optional + +from apscheduler.schedulers.background import BackgroundScheduler + +from config import settings + +logger = logging.getLogger(__name__) + +_scheduler: Optional[BackgroundScheduler] = None +_last_run: dict = { + 'timestamp': None, + 'duration_sec': 0, + 'vessel_count': 0, + 'upserted': 0, + 'error': None, +} + + +def get_last_run() -> dict: + return _last_run.copy() + + +def run_analysis_cycle(): + """5분 주기 분석 사이클 — 인메모리 캐시 기반.""" + from cache.vessel_store import vessel_store + from db import snpdb, kcgdb + from pipeline.orchestrator import ChineseFishingVesselPipeline + from algorithms.location import classify_zone + 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 models.result import AnalysisResult + + start = time.time() + _last_run['timestamp'] = datetime.now(timezone.utc).isoformat() + _last_run['error'] = None + + try: + # 1. 증분 로드 + stale 제거 + if vessel_store.last_bucket is None: + logger.warning('last_bucket is None, skipping incremental fetch (initial load not complete)') + df_new = None + else: + df_new = snpdb.fetch_incremental(vessel_store.last_bucket) + if df_new is not None and len(df_new) > 0: + vessel_store.merge_incremental(df_new) + vessel_store.evict_stale(settings.CACHE_WINDOW_HOURS) + + # 정적정보 / 허가어선 주기적 갱신 + vessel_store.refresh_static_info() + vessel_store.refresh_permit_registry() + + # 2. 분석 대상 선별 (SOG/COG 계산 포함) + df_targets = vessel_store.select_analysis_targets() + if len(df_targets) == 0: + logger.info('no analysis targets, skipping cycle') + _last_run['vessel_count'] = 0 + return + + # 3. 7단계 파이프라인 실행 + pipeline = ChineseFishingVesselPipeline() + classifications, vessel_dfs = pipeline.run(df_targets) + + if not classifications: + logger.info('no vessels classified, skipping') + _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) + + # 5. 선박별 추가 알고리즘 → AnalysisResult 생성 + results = [] + for c in classifications: + mmsi = c['mmsi'] + df_v = vessel_dfs.get(mmsi) + if df_v is None or len(df_v) == 0: + continue + + last_row = df_v.iloc[-1] + ts = last_row.get('timestamp') + + zone_info = classify_zone(last_row['lat'], last_row['lon']) + + gear_map = {'TRAWL': 'OT', 'PURSE': 'PS', 'LONGLINE': 'GN', 'TRAP': 'TRAP'} + gear = gear_map.get(c['vessel_type'], 'OT') + ucaf = compute_ucaf_score(df_v, gear) + ucft = compute_ucft_score(df_v) + + dark, gap_min = is_dark_vessel(df_v) + + spoof_score = compute_spoofing_score(df_v) + speed_jumps = count_speed_jumps(df_v) + bd09_offset = compute_bd09_offset(last_row['lat'], last_row['lon']) + + fleet_info = fleet_roles.get(mmsi, {}) + + is_permitted = vessel_store.is_permitted(mmsi) + risk_score, risk_level = compute_vessel_risk_score( + mmsi, df_v, zone_info, is_permitted=is_permitted, + ) + + activity = 'UNKNOWN' + if 'state' in df_v.columns and len(df_v) > 0: + activity = df_v['state'].mode().iloc[0] + + results.append(AnalysisResult( + mmsi=mmsi, + timestamp=ts, + vessel_type=c['vessel_type'], + confidence=c['confidence'], + fishing_pct=c['fishing_pct'], + cluster_id=c['cluster_id'], + season=c['season'], + zone=zone_info.get('zone', 'EEZ_OR_BEYOND'), + dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0), + activity_state=activity, + ucaf_score=ucaf, + ucft_score=ucft, + is_dark=dark, + gap_duration_min=gap_min, + spoofing_score=spoof_score, + bd09_offset_m=bd09_offset, + speed_jump_count=speed_jumps, + cluster_size=fleet_info.get('cluster_size', 0), + is_leader=fleet_info.get('is_leader', False), + fleet_role=fleet_info.get('fleet_role', 'NOISE'), + risk_score=risk_score, + risk_level=risk_level, + features=c.get('features', {}), + )) + + # 6. 결과 저장 + upserted = kcgdb.upsert_results(results) + kcgdb.cleanup_old(hours=48) + + elapsed = round(time.time() - start, 2) + _last_run['duration_sec'] = elapsed + _last_run['vessel_count'] = len(results) + _last_run['upserted'] = upserted + logger.info( + 'analysis cycle: %d vessels, %d upserted, %.2fs', + len(results), upserted, elapsed, + ) + + except Exception as e: + _last_run['error'] = str(e) + logger.exception('analysis cycle failed: %s', e) + + +def start_scheduler(): + global _scheduler + _scheduler = BackgroundScheduler() + _scheduler.add_job( + run_analysis_cycle, + 'interval', + minutes=settings.SCHEDULER_INTERVAL_MIN, + id='vessel_analysis', + max_instances=1, + replace_existing=True, + ) + _scheduler.start() + logger.info('scheduler started (interval=%dm)', settings.SCHEDULER_INTERVAL_MIN) + + +def stop_scheduler(): + global _scheduler + if _scheduler: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info('scheduler stopped') From 8df1bb8f0f484652c14dc49423c765e750a2a97b Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:17:23 +0900 Subject: [PATCH 09/46] =?UTF-8?q?ci:=20deploy=20=ED=82=A4=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=ED=9B=84=20=EC=9E=AC=EB=B0=B0=ED=8F=AC=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From af088fdcc11b3157cfd9d669d66f9997f054c236 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:20:54 +0900 Subject: [PATCH 10/46] =?UTF-8?q?fix:=20prediction=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20health=20timeout=2060=EC=B4=88=20+=20tar.gz=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 75 ++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2140272..8c8a0fd 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -176,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() From 8c5ba0000c7d995894bbcf3d165c3005440a11ab Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:30:25 +0900 Subject: [PATCH 11/46] =?UTF-8?q?fix:=20=EC=A4=91=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EC=84=A0=EA=B0=90=EC=8B=9C=20=EC=97=B0=EA=B2=B0=EC=84=A0=20?= =?UTF-8?q?=ED=8F=AD=EB=B0=9C=20=E2=80=94=20=EB=B6=80=EB=B6=84=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=A0=9C=EA=B1=B0=20+=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20+=20=EB=A7=88=EC=BB=A4=20=EC=83=81?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gearLinks: 부분 매칭(startsWith) 제거 → 정확 이름 매칭만 - gearLinks: 거리 제한 0.15도(~10NM) 추가 — 원거리 연결선 차단 - gearLinks: 최대 200개 제한 - operating 마커: 최대 100척 - 역할 라벨: 일반 어선(FV) 제외, 본선/부속/운반만 최대 100개 - parentName 최소 3글자 이상만 매칭 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../korea/ChineseFishingOverlay.tsx | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/korea/ChineseFishingOverlay.tsx b/frontend/src/components/korea/ChineseFishingOverlay.tsx index 6131f3b..64eb937 100644 --- a/frontend/src/components/korea/ChineseFishingOverlay.tsx +++ b/frontend/src/components/korea/ChineseFishingOverlay.tsx @@ -100,47 +100,44 @@ export function ChineseFishingOverlay({ ships }: Props) { })); }, [chineseFishing]); - // 조업 중인 선박만 (어구 아이콘 표시용) - const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating), [analyzed]); + // 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척) + const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating).slice(0, 100), [analyzed]); - // 어구/어망 → 모선 연결 탐지 + // 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선) 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(); 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]); @@ -227,8 +224,8 @@ export function ChineseFishingOverlay({ ships }: Props) { ); })} - {/* 본선/부속선/어선 역할 라벨 */} - {analyzed.filter(a => a.role.role).map(({ ship, role }) => ( + {/* 본선/부속선/어선 역할 라벨 (본선/부속/운반만, 최대 100개) */} + {analyzed.filter(a => a.role.role && a.role.role !== 'FV').slice(0, 100).map(({ ship, role }) => (
Date: Fri, 20 Mar 2026 12:47:29 +0900 Subject: [PATCH 12/46] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=EC=96=B4?= =?UTF-8?q?=EC=97=85=EC=88=98=EC=97=AD=20=E2=85=A0~=E2=85=A3=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EA=B3=A4=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=97=AD=20?= =?UTF-8?q?=EB=B6=84=EB=A5=98=20=E2=80=94=20=EA=B2=BD=EB=8F=84=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=20=E2=86=92=20point-in-polygon=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package-lock.json | 66 ++++++++++++++++ frontend/package.json | 2 + frontend/src/data/zones/특정어업수역Ⅰ.json | 1 + frontend/src/data/zones/특정어업수역Ⅱ.json | 1 + frontend/src/data/zones/특정어업수역Ⅲ.json | 1 + frontend/src/data/zones/특정어업수역Ⅳ.json | 1 + frontend/src/utils/fishingAnalysis.ts | 65 ++++++++++++++-- prediction/algorithms/location.py | 88 +++++++++++++++++++++- prediction/data/zones/특정어업수역Ⅰ.json | 1 + prediction/data/zones/특정어업수역Ⅱ.json | 1 + prediction/data/zones/특정어업수역Ⅲ.json | 1 + prediction/data/zones/특정어업수역Ⅳ.json | 1 + 12 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 frontend/src/data/zones/특정어업수역Ⅰ.json create mode 100644 frontend/src/data/zones/특정어업수역Ⅱ.json create mode 100644 frontend/src/data/zones/특정어업수역Ⅲ.json create mode 100644 frontend/src/data/zones/특정어업수역Ⅳ.json create mode 100644 prediction/data/zones/특정어업수역Ⅰ.json create mode 100644 prediction/data/zones/특정어업수역Ⅱ.json create mode 100644 prediction/data/zones/특정어업수역Ⅲ.json create mode 100644 prediction/data/zones/특정어업수역Ⅳ.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b9f283b..161ee08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@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", @@ -1628,6 +1630,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, @@ -3579,6 +3624,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 +3859,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 +4103,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, diff --git a/frontend/package.json b/frontend/package.json index 4caaeb1..175e19f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@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", diff --git a/frontend/src/data/zones/특정어업수역Ⅰ.json b/frontend/src/data/zones/특정어업수역Ⅰ.json new file mode 100644 index 0000000..f0454ef --- /dev/null +++ b/frontend/src/data/zones/특정어업수역Ⅰ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed1", "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\u2160", "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": [[[[14612352.95900835, 4323569.555957972], [14550748.752774281, 4260105.381317261], [14544627.066163512, 4252568.169285575], [14439940.71936106, 4252568.1692174645], [14440259.902536998, 4254382.900417306], [14440565.249736432, 4256577.976660408], [14441200.37191117, 4258322.323996074], [14442128.627396706, 4261947.246114864], [14442446.188484557, 4263842.912458916], [14443081.310658677, 4265407.837348879], [14443838.571713375, 4268086.787104008], [14444461.480000762, 4270299.674084436], [14445414.16326165, 4272528.068283305], [14446488.98540456, 4275811.262361098], [14447111.893690424, 4279125.58051958], [14447441.668665636, 4283375.409280503], [14447441.668666717, 4285908.011073243], [14447747.015866045, 4287008.670616378], [14449298.17963799, 4289692.937620907], [14451325.68504222, 4294897.478106175], [14452583.715503562, 4299470.4800484385], [14452583.715504179, 4299666.724555172], [14452864.634927392, 4301297.200756734], [14452803.565487338, 4303864.187591692], [14452864.634926995, 4306733.892102277], [14452681.426607274, 4309982.105854211], [14452229.512752429, 4313034.803597244], [14451289.043378348, 4315906.938650241], [14450165.365684237, 4319883.836509038], [14448650.843575954, 4323816.808519151], [14447172.963130116, 4326268.076469068], [14445646.22713406, 4328477.720500556], [14443166.807874365, 4331384.242207033], [14440455.324744267, 4333928.090897115], [14438366.74990012, 4335578.885027033], [14435545.341778146, 4337381.416707463], [14435212.858055448, 4337568.367409996], [14433713.258582642, 4338411.570116835], [14431881.17538587, 4339153.947573591], [14430305.583837628, 4339729.704068462], [14430281.156061549, 4340669.162281877], [14432430.800344449, 4344124.648762426], [14433664.40302976, 4347050.554454509], [14434299.525204673, 4348582.044935347], [14435398.775122227, 4352055.241843149], [14436168.250064695, 4355377.820309184], [14436473.59726421, 4359156.794673912], [14436632.377808044, 4361358.014828757], [14437096.505551115, 4363255.98064219], [14438036.97492467, 4367341.541713113], [14438354.536012871, 4371595.823810485], [14438183.541581359, 4375213.2915699165], [14437218.644430902, 4379455.492532148], [14436754.516687542, 4381676.095802414], [14437218.644430652, 4383410.311934654], [14438940.802635401, 4387670.983955953], [14440333.18586435, 4392085.61379955], [14440821.741384165, 4395862.331199512], [14440968.308038985, 4399000.459396788], [14441114.874694768, 4403419.77764905], [14441273.655239271, 4409426.891672738], [14440772.885832038, 4413972.594613021], [14439991.197000964, 4416642.956092686], [14438891.94708353, 4419329.253028348], [14437621.702733777, 4422642.269553811], [14436046.111184767, 4426582.592213162], [14435117.855699727, 4428767.199310407], [14434946.861267168, 4430203.479647634], [14434946.86126783, 4432709.795922216], [14434470.519636003, 4435537.753788483], [14434617.086292291, 4437433.672085769], [14434617.08629216, 4439314.635756483], [14434922.433492135, 4440431.137925814], [14435545.341778962, 4443276.456208943], [14435862.902865293, 4448617.316293129], [14435374.347346198, 4453195.1551512005], [14434433.877972556, 4456978.343560426], [14433493.582733309, 4459337.341378763], [14433493.408598367, 4459337.778245751], [14432540.725337084, 4461222.640432275], [14430134.5894049, 4464840.066412149], [14429071.98115172, 4466066.593934474], [14429377.32835093, 4468244.031514878], [14429487.253342932, 4471664.436166196], [14429487.253342314, 4474871.114707357], [14428962.056159778, 4478201.569995016], [14428339.14787256, 4480581.111676515], [14427423.10627465, 4482961.1914115725], [14426482.636900885, 4485234.2862444], [14424332.992617503, 4488229.992534473], [14422684.117740594, 4490519.594851016], [14421218.451183844, 4492071.890874874], [14420192.484594172, 4493424.573415368], [14418641.32082132, 4495484.674666911], [14415856.554362778, 4498806.286043997], [14415123.721083565, 4501082.812019949], [14413291.637888283, 4505437.2749514375], [14411545.051908031, 4508454.147134834], [14409053.418760045, 4511333.299393252], [14407502.25498812, 4512996.485903551], [14405743.455119297, 4516308.245790257], [14404192.291346865, 4519020.04815391], [14402445.705366325, 4520776.931720719], [14401199.88879329, 4522117.909890488], [14401163.247128874, 4522441.620044785], [14400674.691610316, 4526804.977928812], [14399135.74172542, 4530630.196696397], [14397889.92515128, 4533530.893432845], [14396448.686370509, 4536216.299453886], [14394482.250406215, 4538609.053820163], [14393969.267111044, 4540878.818581772], [14393248.647720557, 4543272.6434066], [14391697.483947853, 4546578.5701886], [14389950.897967545, 4549576.439029636], [14387996.67589144, 4552080.475715166], [14386433.298231108, 4553935.706287525], [14384686.712251175, 4555296.4190314105], [14384063.803963741, 4555698.481870257], [14382817.987391062, 4558497.888716806], [14382023.990793852, 4559433.839678707], [14379825.584836785, 4562025.286767265], [14377993.501640612, 4563882.293207642], [14375709.504589545, 4565538.404034689], [14373230.085329972, 4569687.579878523], [14370848.377174843, 4572490.760700769], [14369101.79119466, 4574132.75433843], [14367978.113501683, 4575372.167021386], [14367025.430239363, 4578161.387517195], [14365584.191457871, 4580563.818041922], [14364155.16656508, 4582966.805917671], [14362591.788904771, 4585230.77563233], [14360136.797420906, 4586983.366182029], [14359501.67524686, 4589031.016082314], [14357755.089266032, 4591730.810433944], [14356924.544884088, 4593049.931812809], [14355397.808887746, 4596589.112554951], [14353321.447931753, 4599694.655522917], [14351355.011967713, 4602086.560165967], [14350548.895361273, 4602909.876326975], [14349058.80102805, 4604059.521970236], [14348362.609413402, 4605286.987737952], [14347006.867848393, 4607571.395078686], [14345260.281868543, 4610058.400036733], [14344344.240270587, 4614925.339107906], [14344178.729650684, 4615426.598601238], [14406264.563155375, 4615426.598601238], [14471145.302268442, 4615426.598601238], [14489820.50078106, 4579817.049246806], [14657058.866457367, 4579819.039140932], [14657058.866471501, 4498513.035634587], [14653280.330118885, 4484660.955595197], [14653257.89496764, 4484604.528273547], [14653111.328311926, 4484251.265963233], [14652952.547767457, 4483867.298646529], [14652805.981111629, 4483498.703204842], [14652793.767223712, 4483437.271887497], [14652732.697783664, 4483283.695161308], [14652573.91724023, 4482899.763155043], [14652463.992248593, 4482531.201607391], [14652317.425592588, 4482147.2970551355], [14652183.072824666, 4481778.761862163], [14652085.361720739, 4481394.884757528], [14651963.222840805, 4481011.021654676], [14651853.297849245, 4480627.172552061], [14651731.158969458, 4480243.337446124], [14651645.661754325, 4479828.811251124], [14651560.164537868, 4479460.357225606], [14651450.239546517, 4479061.213248133], [14651376.956218421, 4478677.435232205], [14651303.672890497, 4478278.320934065], [14651230.38956299, 4477894.571451181], [14651181.534010744, 4477495.486823346], [14651120.464571165, 4477096.417317753], [14651059.395131014, 4476712.710899764], [14650998.325691152, 4476313.671051835], [14650996.99554685, 4476290.271738564], [14645926.917379338, 4457703.411105691], [14630424.731020536, 4444761.216899179], [14601399.121065676, 4420528.823331998], [14513278.61218791, 4420528.823356817], [14513278.612126667, 4323569.5559741575], [14612352.95900835, 4323569.555957972]], [[14531705.281810218, 4513797.373626424], [14531693.067921922, 4513597.145949486], [14531680.854034334, 4513381.520424651], [14531680.854033662, 4513196.702081734], [14531656.42625728, 4512981.084804853], [14531631.99848197, 4512596.064997208], [14531631.998481335, 4512395.860290817], [14531631.998482231, 4512180.259503853], [14531619.784594133, 4512010.861999429], [14531619.784593917, 4511795.269138], [14531619.784594417, 4511579.680716071], [14531619.784594513, 4511394.89417161], [14531619.784594564, 4511194.712427556], [14531631.998481907, 4510979.136366602], [14531631.99848219, 4510794.36041791], [14531631.998481516, 4510578.792596463], [14531656.426257836, 4510193.861093197], [14531680.854033632, 4509993.7023007], [14531680.854033832, 4509793.547333112], [14531693.067922262, 4509593.396189629], [14531705.28180977, 4509393.248870632], [14531741.923473405, 4509193.105373775], [14531754.137361716, 4508992.965701181], [14531778.565137729, 4508608.0924608875], [14531827.420689235, 4508207.83928627], [14531888.490129804, 4507807.60139917], [14531937.345681723, 4507407.378797934], [14531986.201233227, 4507022.563787677], [14532071.698448502, 4506622.371163723], [14532144.981777469, 4506222.193819813], [14532218.26510484, 4505822.031752334], [14532291.54843238, 4505437.274939068], [14532389.259536454, 4505052.532246742], [14532486.970639894, 4504667.803672876], [14532584.681744624, 4504283.089215654], [14532682.3928476, 4503898.388874675], [14532804.5317276, 4503498.31548994], [14532816.74561534, 4503467.54124596], [14532841.173391888, 4503236.737298468], [14532890.028944094, 4502836.689154722], [14532951.098383617, 4502436.656270566], [14533036.595599566, 4502036.638641951], [14533085.451150997, 4501652.020693331], [14533170.94836721, 4501252.032985861], [14533256.445583222, 4500882.827099174], [14533329.728910444, 4500467.486007207], [14533427.440014655, 4500082.925582424], [14533512.937230268, 4499698.379251896], [14533537.365005817, 4499636.85314637], [14533635.07610988, 4499313.847011714], [14533720.573326208, 4498929.328863146], [14533842.71220557, 4498544.824803407], [14533964.851085538, 4498160.334828932], [14534086.989965722, 4497775.8589393], [14534221.342733555, 4497406.77533439], [14534343.481613029, 4497022.3270443855], [14534490.048267843, 4496653.269931789], [14534636.614923632, 4496284.225792352], [14534795.395467635, 4495899.818607236], [14534941.962123044, 4495530.800950158], [14535088.528779598, 4495161.796260659], [14535271.737098787, 4494808.178934617], [14535442.73153039, 4494454.573515013], [14535625.939850742, 4494085.606640488], [14535821.362058103, 4493732.025548209], [14535992.356489455, 4493363.084053765], [14536187.77869705, 4493024.899068225], [14536370.987016352, 4492671.353679356], [14536578.623112632, 4492333.190962355], [14536798.473096136, 4491979.668849295], [14537006.10919119, 4491656.8981793765], [14537225.959175108, 4491303.398821744], [14537421.381382758, 4490980.648924496], [14537653.44525381, 4490642.540617242], [14537909.936901638, 4490319.811016954], [14538129.786885347, 4489997.091326332], [14538374.0646448, 4489659.014660098], [14538618.342404164, 4489351.681672938], [14538850.406276468, 4489044.357672096], [14539131.325698882, 4488721.677141822], [14539363.389571583, 4488429.736623869], [14539644.308994643, 4488122.43957564], [14539900.800642235, 4487815.151508973], [14539986.297858382, 4487722.966840681], [14540010.725633759, 4487692.23879659], [14540267.217281476, 4487400.32686548], [14540511.495040976, 4487077.696790041], [14540780.200575706, 4486770.43925529], [14541073.333887419, 4486493.915150635], [14541329.825535271, 4486186.674672224], [14541598.531071173, 4485910.165917298], [14541867.23660634, 4485618.303450893], [14542148.156029876, 4485326.449084601], [14542453.503228514, 4485049.962943723], [14542734.422652928, 4484773.484071729], [14543039.76985148, 4484512.371808684], [14543332.903163565, 4484251.266027703], [14543638.250362527, 4483974.808145135], [14543943.59756212, 4483713.715705612], [14544248.944761822, 4483467.987564921], [14544566.505848715, 4483222.26516165], [14544871.85304831, 4482976.548497614], [14545213.841912381, 4482746.194336815], [14545519.189110842, 4482515.845218762], [14545836.750198007, 4482254.788980392], [14546190.952949973, 4482024.450618634], [14546508.514037313, 4481824.828116437], [14546838.28901309, 4481609.854272898], [14547180.27787654, 4481394.88481907], [14547510.052850928, 4481179.919756588], [14547876.469490254, 4480995.667482875], [14548218.458354343, 4480796.06449436], [14548560.447216801, 4480596.465289407], [14548926.863856547, 4480412.223228773], [14549293.280496065, 4480212.631302457], [14549647.483247736, 4480043.748782027], [14549989.472111017, 4479874.868972281], [14550355.88875037, 4479721.344221679], [14550624.594284926, 4479598.526033944], [14550856.658157144, 4479491.061295501], [14551198.647020763, 4479291.48683264], [14551565.063659478, 4479107.26761045], [14551919.266411318, 4478923.051610395], [14552285.683050882, 4478769.540739722], [14552627.671913499, 4478600.681367721], [14553006.302441431, 4478431.8247021455], [14553372.719079891, 4478278.320992562], [14553739.135719031, 4478124.8195214365], [14554117.766246844, 4477986.670110905], [14554496.39677429, 4477833.172889764], [14554875.02730134, 4477710.376725644], [14555253.657828972, 4477572.232751319], [14555620.074468583, 4477464.788689573], [14555998.704995206, 4477326.647937234], [14556377.335522415, 4477234.555108875], [14556511.688290423, 4477157.811699739], [14556878.10492996, 4476958.281453992], [14557220.093792727, 4476804.799222015], [14557598.724319693, 4476651.319227265], [14557952.92707147, 4476482.493814672], [14558331.557598298, 4476329.018516568], [14558697.974237733, 4476175.54545325], [14559076.604765655, 4476022.0746261515], [14559443.02140455, 4475883.952793535], [14559821.65193224, 4475761.179352402], [14560200.282459686, 4475638.407340859], [14560578.912986, 4475500.290538945], [14560957.54351379, 4475377.521568426], [14561348.38792862, 4475270.099892532], [14561727.01845645, 4475147.333604416], [14562117.862871993, 4475055.259828022], [14562484.27951158, 4474963.186855402], [14562899.551702326, 4474871.1146875555], [14563290.396117546, 4474809.733688274], [14563669.026644476, 4474733.007943433], [14564059.871060286, 4474656.282757424], [14564450.7154752, 4474579.558129849], [14564853.773778029, 4474518.178830546], [14565244.618193747, 4474456.79988746], [14565635.462609466, 4474395.4213030925], [14565843.09870468, 4474380.076712145], [14566026.307024052, 4474364.732145027], [14566429.365327647, 4474303.354096425], [14566624.787535438, 4474303.354096845], [14566820.209743189, 4474288.009639743], [14567040.059725929, 4474272.665205484], [14567235.481933901, 4474241.97640493], [14567430.904141523, 4474241.9764056895], [14567638.540236901, 4474226.632038639], [14567821.748556953, 4474226.632038577], [14568029.384652914, 4474211.287694249], [14568212.592971867, 4474211.287693342], [14568420.229068192, 4474195.943371697], [14568627.865163937, 4474195.943371153], [14568811.073482776, 4474195.943371623], [14569018.709579367, 4474195.94337142], [14569214.131786728, 4474195.943370581], [14569238.559562922, 4474195.943371392], [14569409.553994717, 4474195.943371547], [14569629.403978188, 4474195.943370894], [14569824.826185605, 4474211.287692922], [14570020.248392954, 4474211.287693488], [14570215.670600649, 4474211.287693272], [14570423.306696696, 4474226.632037414], [14570618.728903888, 4474226.632037795], [14570814.151111197, 4474241.97640376], [14571021.787206706, 4474272.665204512], [14571204.995527001, 4474288.009638165], [14571412.631621836, 4474288.009637828], [14571620.267718533, 4474303.354094652], [14572023.326021364, 4474364.732141971], [14572194.32045334, 4474380.076710127], [14572414.170436617, 4474395.421300662], [14572805.01485221, 4474456.799885332], [14573208.073155506, 4474518.178827347], [14573598.917569762, 4474579.558126053], [14573989.761985833, 4474656.282754001], [14574368.392513085, 4474733.007939714], [14574759.236928629, 4474809.733685015], [14575162.295230972, 4474871.114683236], [14575553.139646066, 4474963.186850651], [14575931.770173518, 4475055.259823188], [14576322.614588926, 4475147.3335995795], [14576701.245116178, 4475270.0998873925], [14577079.875643862, 4475377.521562786], [14577470.720059728, 4475500.290533416], [14577837.13669783, 4475638.407335261], [14578215.767225962, 4475761.179345143], [14578594.39775321, 4475883.95278683], [14578973.028280452, 4476022.074619254], [14579327.231032163, 4476175.54544557], [14579705.861558419, 4476329.018508264], [14580084.492086556, 4476482.493807283], [14580450.908725094, 4476651.3192181215], [14580805.111476777, 4476804.799213971], [14581183.742004093, 4476958.281445817], [14581525.730867168, 4477157.811690843], [14581879.933618782, 4477326.647927245], [14582234.136370221, 4477526.185151994], [14582588.3391218, 4477710.376715044], [14582930.327984763, 4477909.9212099165], [14583296.744624889, 4478094.119485319], [14583626.51959986, 4478309.021544471], [14583968.508463632, 4478539.278618473], [14584298.28343856, 4478754.189764326], [14584615.844526524, 4478969.105295983], [14584957.833389819, 4479199.376805741], [14585275.39447627, 4479429.653352519], [14585592.955563627, 4479659.934935787], [14585922.730539948, 4479890.221556971], [14586240.291627208, 4480135.866171305], [14586557.8527144, 4480396.869855059], [14586863.199913831, 4480642.526293688], [14587156.33322539, 4480888.188466244], [14587473.894312968, 4481164.565263089], [14587767.027623786, 4481425.59445718], [14588060.160935674, 4481701.985366741], [14588353.294247115, 4481978.383535463], [14588646.427559184, 4482254.788964908], [14588939.56086965, 4482546.558126468], [14589208.266406132, 4482822.978480075], [14589476.971941242, 4483114.763399049], [14589733.46358897, 4483421.914160338], [14590014.383012347, 4483698.35751204], [14590185.377443707, 4483898.0155623555], [14590331.944099901, 4484020.883937888], [14590661.719074445, 4484251.266009965], [14590979.280162634, 4484497.0124473], [14591296.841249231, 4484727.404948229], [14591602.18844864, 4484973.162509527], [14591919.749536166, 4485234.28621192], [14592212.882847624, 4485495.41639687], [14592518.23004711, 4485756.553064018], [14592823.577246739, 4486017.696217524], [14593116.710558899, 4486294.207799355], [14593409.843869546, 4486570.726653404], [14593690.763293965, 4486847.252777424], [14593983.89660544, 4487139.149354413], [14594252.602139814, 4487431.054034741], [14594533.52156419, 4487692.238776791], [14594802.227099039, 4487999.523250918], [14595058.718747094, 4488306.8167062495], [14595339.638170302, 4488598.753809731], [14595583.915929569, 4488906.064781649], [14595828.19368928, 4489213.384739871], [14596096.899225544, 4489536.080365369], [14596341.176984914, 4489858.785899267], [14596573.240855644, 4490181.501342655], [14596805.304727584, 4490488.858596873], [14597037.368599355, 4490826.961959009], [14597257.21858321, 4491149.707135566], [14597489.282454617, 4491472.462225323], [14597696.918549843, 4491825.967271368], [14597928.98242226, 4492148.743134879], [14598136.618516896, 4492502.270936908], [14598319.826836484, 4492840.439096991], [14598527.462932773, 4493193.990176503], [14598722.885140764, 4493547.553156926], [14598906.093460135, 4493901.128039849], [14599101.515668057, 4494254.714825081], [14599260.296211885, 4494623.687640952], [14599431.290642768, 4494977.2987543205], [14599590.07118727, 4495330.921775762], [14599773.279505912, 4495715.308132935], [14599919.846161587, 4496068.956009824], [14600078.626705699, 4496453.369387598], [14600225.193361096, 4496822.419472711], [14600347.33224078, 4497206.860440532], [14600481.685009632, 4497575.937016725], [14600616.037777228, 4497960.4055835055], [14600750.39054488, 4498344.888232693], [14600860.31553649, 4498714.004829062], [14600982.454416526, 4499098.51508797], [14601080.165519364, 4499483.0394360265], [14601202.304399468, 4499867.577874515], [14601300.01550408, 4500267.512801375], [14601385.512719708, 4500652.079992499], [14601458.796047695, 4501052.044824842], [14601471.009935707, 4501175.113995761], [14601654.218255237, 4501498.177436593], [14601800.784910476, 4501867.404980572], [14601971.779342758, 4502236.64552286], [14602118.34599798, 4502590.513240334], [14602277.126541303, 4502959.779238895], [14602423.693198, 4503344.4451490035], [14602558.045964886, 4503698.350247777], [14602680.184845533, 4504083.043249815], [14602814.537612794, 4504467.750366676], [14602936.676492875, 4504852.471599208], [14603058.815372452, 4505237.206950171], [14603180.954252187, 4505606.56617041], [14603266.451468613, 4506022.110849317], [14603388.590348229, 4506391.497726757], [14603474.087563867, 4506791.681535702], [14603547.37089101, 4507161.095535717], [14603645.08199488, 4507561.308730727], [14603718.365323769, 4507946.143524667], [14603791.648650708, 4508330.992452457], [14603864.931978848, 4508746.64517058], [14603926.001419289, 4509131.523501303], [14603987.070858913, 4509531.8119634325], [14604035.92641036, 4509916.719140585], [14604084.781962857, 4510332.43477583], [14604109.20973883, 4510717.37137208], [14604145.85140303, 4510932.942045082], [14604158.065290203, 4511117.720440137], [14604170.279178778, 4511317.900712452], [14604170.279177956, 4511533.483745455], [14604182.493066223, 4511718.272735513], [14604219.134730231, 4511933.864012004], [14604219.134729845, 4512318.859471839], [14604231.348618282, 4512519.062705031], [14604231.348618418, 4512719.269766486], [14604231.348617738, 4512904.079682778], [14604231.348618373, 4513119.695373501], [14604231.348618187, 4513319.913919597], [14604231.348618748, 4513520.136295258], [14604231.348618748, 4513720.362499544], [14604231.348618232, 4513920.592533981], [14604231.348618407, 4514120.826397525], [14604219.134730808, 4514336.467148453], [14604219.134730032, 4514721.550967597], [14604182.493066857, 4514906.396233077], [14604170.27917791, 4515122.053169531], [14604170.279178878, 4515322.310016387], [14604158.065290527, 4515507.165891377], [14604145.851402044, 4515722.835206429], [14604109.209738161, 4515923.103548852], [14604084.781962737, 4516308.245749079], [14604035.926410299, 4516708.808674816], [14603987.070858993, 4517093.9797944045], [14603926.00141845, 4517509.9805284925], [14603864.931978678, 4517895.181143359], [14603791.64865124, 4518295.804826711], [14603718.365322556, 4518681.034375972], [14603645.081995493, 4519097.098223365], [14603547.370891701, 4519466.946648656], [14603474.08756368, 4519867.630533778], [14603388.59034769, 4520237.506207204], [14603266.451468341, 4520638.219615408], [14603180.95425151, 4521008.12254481], [14603058.815372169, 4521408.865484192], [14602936.676491957, 4521778.79567707], [14602814.537612109, 4522164.153545156], [14602680.184844451, 4522534.110462632], [14602558.045964777, 4522919.496172441], [14602423.693197738, 4523289.47982265], [14602277.126541242, 4523674.893382433], [14602118.345997736, 4524044.903771376], [14601971.779341936, 4524414.927259076], [14601800.784909926, 4524784.963848068], [14601654.218254454, 4525155.013540586], [14601471.009935107, 4525525.076335961], [14601300.015503855, 4525864.3120796885], [14601129.021072082, 4526234.399998049], [14600933.598864602, 4526573.658772662], [14600750.390544403, 4526928.350179163], [14600542.754449246, 4527267.63148988], [14600347.332241392, 4527637.769124864], [14600151.910033902, 4527961.6503126025], [14599944.273938052, 4528316.388852858], [14599712.210066758, 4528640.29108562], [14599504.573970841, 4528964.203363799], [14599272.510098685, 4529318.975958536], [14599052.660115397, 4529627.483662931], [14598808.382356219, 4529951.426563321], [14598588.532372601, 4530275.379513526], [14598344.254612468, 4530599.342514882], [14598087.762964793, 4530907.88805286], [14597831.271317031, 4531216.442710067], [14597586.993558556, 4531525.006485532], [14597318.288022641, 4531833.57938278], [14597061.796375385, 4532126.732084111], [14596793.090839129, 4532419.893019322], [14596512.171415407, 4532713.062188578], [14596231.251992663, 4533021.670209015], [14595950.332568703, 4533299.42523091], [14595669.413144821, 4533577.187645228], [14595644.985369092, 4533623.48209964], [14595498.41871387, 4533777.7984313145], [14595376.279834235, 4533916.685081554], [14595119.788186248, 4534225.328697573], [14594838.868762594, 4534518.548590586], [14594582.377115823, 4534811.776722892], [14594289.243803782, 4535089.579398674], [14594008.324380705, 4535367.389470016], [14593727.404956257, 4535660.64146122], [14593446.48553373, 4535923.031808229], [14593153.352221377, 4536200.864075813], [14592848.005022287, 4536478.703743617], [14592567.08559909, 4536741.114669866], [14592249.524511898, 4537003.532198354], [14591944.177311765, 4537250.519432793], [14591614.402336147, 4537497.512515288], [14591309.055137279, 4537729.073843565], [14591003.707938092, 4537991.5162313925], [14590686.146850547, 4538238.5268653], [14590368.585762527, 4538454.665970274], [14590014.383011634, 4538686.248553525], [14589684.608036457, 4538917.836279545], [14589354.833060294, 4539118.549804093], [14589025.058085084, 4539319.267192334], [14588683.069222417, 4539535.42869948], [14588328.866470784, 4539736.154114145], [14587986.877606917, 4539952.324265979], [14587657.102632334, 4540137.616535028], [14587290.685992181, 4540338.353544246], [14586936.483240709, 4540508.210955269], [14586570.066601887, 4540678.071133686], [14586228.077738127, 4540863.376303522], [14585861.661099326, 4541033.242270953], [14585495.244460465, 4541187.668278762], [14585128.827820107, 4541342.096575501], [14584750.197293801, 4541496.527160034], [14584395.994541308, 4541650.960032226], [14584347.138989441, 4541666.403445655], [14584286.06955062, 4541697.29033999], [14583919.652910553, 4541851.726188135], [14583565.450158978, 4542021.60826263], [14583186.819632547, 4542176.048916555], [14582844.83076899, 4542330.491858846], [14582466.20024182, 4542484.937089604], [14582087.569714688, 4542608.49492355], [14581708.939186862, 4542762.94427407], [14581330.308658957, 4542901.950648047], [14580963.892020464, 4543010.067999098], [14580585.261492236, 4543149.07766965], [14580206.630964926, 4543257.1975837825], [14579803.57266225, 4543365.318620336], [14579424.942134961, 4543457.994687216], [14579192.878263632, 4543519.779190642], [14579034.09771989, 4543550.671579929], [14578655.467192937, 4543658.795661787], [14578264.622776985, 4543751.474338827], [14577873.77836218, 4543828.707200758], [14577495.147835061, 4543905.940633789], [14577092.089531014, 4543983.174640503], [14576701.245115522, 4544029.515319117], [14576310.400701316, 4544106.750241843], [14575919.556285223, 4544137.6443707235], [14575699.706302246, 4544183.98573635], [14575504.284093758, 4544199.432905103], [14575113.439679246, 4544230.327308818], [14574722.595264157, 4544276.669086714], [14574514.959168296, 4544292.1163917575], [14574331.750849022, 4544307.563720322], [14574124.11475319, 4544307.563719708], [14573916.478657782, 4544338.458445055], [14573733.270338044, 4544353.905841484], [14573525.634242143, 4544353.905841748], [14573330.212034652, 4544369.353261512], [14573134.789826233, 4544369.353261464], [14572939.367619064, 4544369.353262303], [14572719.517635329, 4544369.353261675], [14572524.095428342, 4544369.353261389], [14572328.673220538, 4544384.800704624], [14572121.037124906, 4544384.800704819], [14571937.828804424, 4544369.353261591], [14571730.192709187, 4544369.35326217], [14571522.556614075, 4544369.353262826], [14571339.34829391, 4544369.353262385], [14571131.712198837, 4544353.905842261], [14570948.503878202, 4544353.905843028], [14570740.867782762, 4544338.45844611], [14570545.44557518, 4544338.458445838], [14570350.023367973, 4544307.563721146], [14570130.173384072, 4544292.116393631], [14569934.751176836, 4544276.669089307], [14569543.906761209, 4544230.327311454], [14569153.062345682, 4544199.432906603], [14568945.42625058, 4544183.985738961], [14568762.21793041, 4544137.6443743445], [14568359.15962792, 4544106.750244441], [14567968.315212548, 4544029.515323136], [14567577.470797084, 4543983.174644471], [14567186.6263823, 4543905.940638149], [14566783.568079067, 4543828.707204437], [14566392.72366387, 4543751.474343973], [14566014.093135444, 4543658.795666206], [14565623.24872121, 4543550.671584686], [14565244.618193153, 4543457.994693514], [14564853.773778267, 4543365.3186267475], [14564475.14325144, 4543257.19758965], [14564096.512723364, 4543149.07767554], [14563693.454420516, 4543010.068005312], [14563314.823893422, 4542901.950654995], [14562936.193365432, 4542762.944282519], [14562606.418390313, 4542716.609237178], [14562398.782294482, 4542685.71932119], [14562191.146199709, 4542670.274397315], [14561812.51567194, 4542608.494931122], [14561421.671257151, 4542562.160572617], [14561030.826841386, 4542500.381747122], [14560639.982426064, 4542423.158731613], [14560236.924122458, 4542345.936288569], [14559833.86581993, 4542268.714416368], [14559467.449180512, 4542176.048925813], [14559076.604765026, 4542083.384260142], [14558697.97423732, 4541990.720416876], [14558307.129823012, 4541867.169908754], [14557928.499294864, 4541774.507987912], [14557525.440991675, 4541650.960043525], [14557146.81046419, 4541542.856792612], [14556768.179936875, 4541403.868545181], [14556389.549410133, 4541264.882152807], [14556010.918883575, 4541141.3402487645], [14555644.502244113, 4541017.7998076], [14555290.299492834, 4540863.376315873], [14554911.668964645, 4540708.95511347], [14554557.466213938, 4540523.652687981], [14554178.835686168, 4540384.678031769], [14553824.632935008, 4540214.822632615], [14553458.216296038, 4540044.970003144], [14553116.227432424, 4539844.238643887], [14552749.810793048, 4539689.83253557], [14552407.821930347, 4539504.548224409], [14552249.041385714, 4539427.347399758], [14551882.624746233, 4539303.827270621], [14551503.99422004, 4539195.748356934], [14551381.855340248, 4539149.4291654825], [14551125.36369234, 4539072.230970899], [14550746.73316546, 4538948.715047952], [14550368.102638047, 4538825.200587222], [14549989.472110914, 4538686.24856897], [14549623.055470902, 4538531.8596091075], [14549244.42494422, 4538377.472935194], [14548902.436080279, 4538238.526881961], [14548523.805553462, 4538084.144551083], [14548157.38891405, 4537914.3266261555], [14547803.186162714, 4537729.073861541], [14547436.769523405, 4537559.261718393], [14547094.7806594, 4537404.8894423675], [14546728.364020523, 4537204.208898579], [14546361.947381083, 4537018.9687477425], [14546032.172405548, 4536818.295628578], [14545677.969654717, 4536633.062331327], [14545323.76690356, 4536432.396637806], [14544993.991928555, 4536216.299437661], [14544652.003064753, 4536000.206715432], [14544322.228089612, 4535799.553195227], [14544004.667001592, 4535568.034697714], [14543662.678138765, 4535351.9554023], [14543345.117051568, 4535120.44683895], [14543027.555963451, 4534858.076675749], [14542697.780988807, 4534626.579069244], [14542380.219901314, 4534395.086599067], [14542074.87270228, 4534132.734675581], [14541781.73939042, 4533870.389347416], [14541464.178302774, 4533623.482121268], [14541158.831103068, 4533345.718475012], [14540877.911680002, 4533098.82366223], [14540572.564480469, 4532821.073979021], [14540291.645057205, 4532543.331687368], [14540010.725633612, 4532265.5967861395], [14539729.806210512, 4531972.440185714], [14539448.886786574, 4531679.291817728], [14539192.395139629, 4531417.008149412], [14538899.261827474, 4531108.447566406], [14538630.55629233, 4530815.323457989], [14538374.064645067, 4530506.780656056], [14538129.786885653, 4530198.2469714265], [14537873.295237642, 4529874.296414274], [14537616.803589916, 4529565.781417542], [14537396.953606762, 4529257.275535442], [14537152.67584749, 4528933.354167178], [14536932.825863078, 4528624.866966413], [14536676.334216729, 4528270.117950614], [14536444.27034455, 4527946.227197342], [14536248.848136436, 4527606.923846032], [14536028.998153169, 4527267.631515431], [14535821.362057582, 4526928.3502052585], [14535601.512074055, 4526589.079913595], [14535418.30375431, 4526249.820638423], [14535210.667658564, 4525910.572378158], [14535015.245451855, 4525555.915520818], [14534832.037131598, 4525185.851631445], [14534673.256587537, 4524831.219369989], [14534490.048268745, 4524476.599140016], [14534331.267724525, 4524106.573469703], [14534160.273292877, 4523751.977827433], [14533989.278861778, 4523366.561424789], [14533842.71220565, 4523027.406744612], [14533696.145549532, 4522642.01705591], [14533561.79278178, 4522287.47108967], [14533403.01223859, 4521902.108676457], [14533268.659471177, 4521532.174121515], [14533134.306703577, 4521146.839544288], [14533036.59559936, 4520776.931708183], [14532914.45671886, 4520376.21298474], [14532804.531727992, 4520006.332405602], [14532682.392847996, 4519621.054045723], [14532596.895632427, 4519220.379606621], [14532499.184527747, 4518835.130195337], [14532413.687312467, 4518449.894972147], [14532303.762320925, 4518049.265387172], [14532230.478993248, 4517664.059100537], [14532157.19566512, 4517278.866996087], [14532108.340113258, 4516878.282249036], [14532035.056785649, 4516477.712836037], [14531973.987345573, 4516092.564399366], [14531973.987345243, 4516030.941964629], [14531937.345681304, 4515784.455855288], [14531888.490129516, 4515383.928306678], [14531827.420689756, 4514983.416085525], [14531778.56513731, 4514582.919189623], [14531741.923473246, 4513982.2025730265], [14531705.281810218, 4513797.373626424]]], [[[14339432.408530401, 4075075.6362608722], [14339458.685080042, 4075084.437250298], [14339751.818391822, 4076223.538724107], [14338652.568473613, 4084644.61678336], [14338506.00181847, 4086228.895738871], [14336759.41583827, 4097428.8401910467], [14341315.196051706, 4104501.207588504], [14341938.104338527, 4105925.114876204], [14342707.57928172, 4107200.8516809675], [14343501.482000086, 4108610.2642060244], [14352735.181309098, 4124392.929620267], [14356472.63102952, 4130694.3846049826], [14357889.442034634, 4133221.908237402], [14359770.380782299, 4136374.679519459], [14361968.880617706, 4140004.4420440258], [14364619.294308105, 4144900.5395199214], [14371984.268757481, 4157187.447747604], [14376991.962826528, 4165401.170055259], [14381535.529153243, 4172024.2679253733], [14386396.65656731, 4178188.490732939], [14390109.678512042, 4182503.992319043], [14410071.323094087, 4181759.7407464916], [14411740.474114683, 4181697.507984717], [14411887.04077094, 4182668.28335924], [14412290.099073624, 4183937.8871818185], [14413145.071231768, 4185521.361705531], [14414659.593340822, 4189466.094009724], [14416235.184889486, 4190347.874119261], [14418116.123638438, 4191708.045624967], [14420009.276274431, 4193442.1331010573], [14421670.365039108, 4195430.688718817], [14423087.176044246, 4197314.91349072], [14424369.634281721, 4199438.789978044], [14425468.884199308, 4201682.765014417], [14426323.856356785, 4203642.876257398], [14426897.90909205, 4205708.102752736], [14427508.603490569, 4207998.262274081], [14427826.164578045, 4210229.005413773], [14428705.564512718, 4211007.626033115], [14430427.722717127, 4212669.865587721], [14432333.089241156, 4214736.783326548], [14433591.119701806, 4217118.712887043], [14434763.65294765, 4219576.085746894], [14435569.76955409, 4222498.676121303], [14436070.538960757, 4223802.851526747], [14436840.01390352, 4227416.375070025], [14437157.574990707, 4228856.1177854985], [14437963.691597, 4230581.057929868], [14439343.860938719, 4234526.939401433], [14440296.544200161, 4238774.472618673], [14440443.110856375, 4242242.7597973915], [14440113.33588104, 4245862.359133215], [14439478.213706143, 4248867.084183436], [14439331.647050746, 4249813.743335828], [14439649.208137836, 4250910.768227175], [14439940.719361056, 4252568.169217453], [14544627.066163512, 4252568.169285575], [14534796.669683114, 4240464.677659911], [14514759.161462417, 4205175.173351611], [14501957.419936124, 4179737.9296527705], [14485448.739516629, 4179288.840961339], [14439555.743282635, 4173635.0297790095], [14435722.322889304, 4173166.719291172], [14421250.789188549, 4166599.3964159037], [14402137.232618976, 4158446.631542469], [14394344.868232908, 4150978.506075684], [14389524.734276652, 4146676.4225404835], [14384326.1140242, 4142606.5776094557], [14365958.39800865, 4119341.9682434443], [14361694.86160705, 4101989.3419181844], [14361694.861568779, 4100867.6858379836], [14360581.666636346, 4094329.1704151263], [14359101.117467742, 4090737.3044364187], [14347779.925183792, 4070274.6906909533], [14347742.667809354, 4070231.176358625], [14339432.408530401, 4075075.6362608722]]]]}}]} \ No newline at end of file diff --git a/frontend/src/data/zones/특정어업수역Ⅱ.json b/frontend/src/data/zones/특정어업수역Ⅱ.json new file mode 100644 index 0000000..5f3cea7 --- /dev/null +++ b/frontend/src/data/zones/특정어업수역Ⅱ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed2", "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\u2161", "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": [[[[14026312.49388151, 3787395.72363925], [14026272.928939708, 3912341.809856742], [14026343.45295978, 3912257.596201178], [14026661.014047539, 3911892.988733094], [14026978.575133963, 3911557.559921697], [14027051.858461797, 3911470.0583396605], [14027137.355677672, 3911178.3911333913], [14027198.425118214, 3910930.4797375146], [14027271.708445255, 3910711.7387614], [14027344.991773328, 3910478.419569922], [14027406.061213229, 3910245.105041409], [14027503.77231727, 3910026.376906358], [14027577.055645473, 3909793.071410766], [14027650.338973064, 3909574.351742198], [14027723.622300655, 3909341.0552779627], [14027821.333404718, 3909122.34407479], [14027906.830620117, 3908903.6369685275], [14027992.327835856, 3908684.9339581975], [14028090.038940514, 3908451.65526086], [14028175.536155386, 3908218.3812227794], [14028273.247259233, 3908014.2702603256], [14028370.958363216, 3907781.0049572694], [14028468.669467552, 3907576.9016377437], [14028566.380571473, 3907343.6450677463], [14028664.091674816, 3907139.5493900776], [14028761.8027788, 3906920.8794053984], [14028883.941658128, 3906716.7911130013], [14028993.866650797, 3906483.5517138042], [14029091.577753998, 3906279.4710601526], [14029201.502746, 3906060.81717149], [14029323.641625034, 3905856.7438994534], [14029433.566617623, 3905638.097921009], [14029543.491609104, 3905419.4560323786], [14029665.630488753, 3905215.393960793], [14029714.486040367, 3905011.3354516793], [14029763.341592517, 3904807.2805052707], [14029824.411032889, 3904574.0792145403], [14029861.052696653, 3904340.8825751734], [14029922.122136267, 3904107.690588767], [14029983.19157568, 3903874.5032539973], [14030044.261016268, 3903641.3205705], [14030105.33045631, 3903393.5690651014], [14030178.613783477, 3903174.96915455], [14030239.683223227, 3902941.800421957], [14030312.96655101, 3902723.2089581615], [14030386.24987901, 3902475.4769059164], [14030447.319319358, 3902256.8941590027], [14030520.602646638, 3902023.7437318605], [14030606.099863403, 3901790.597953613], [14030691.597078484, 3901572.028007055], [14030764.880406654, 3901338.8912325953], [14030862.591510149, 3901120.329726406], [14030935.874838341, 3900887.201954522], [14031021.372054312, 3900668.648888896], [14031119.083157621, 3900450.0999057423], [14031204.580374023, 3900216.9854906956], [14031290.077589095, 3899998.444944336], [14031400.002581708, 3899779.908480802], [14031485.499797242, 3899561.37609934], [14031595.424789447, 3899342.847798803], [14031705.349781059, 3899138.891733757], [14031790.846996775, 3898905.803441251], [14031912.985876147, 3898701.8549923794], [14032010.696979966, 3898468.7754048174], [14032120.621971566, 3898264.8345725327], [14032230.54696337, 3898046.330481661], [14032352.685843932, 3897827.830470188], [14032450.396947037, 3897623.9008064135], [14032572.535826314, 3897405.4086795547], [14032694.674706502, 3897216.052135809], [14032816.81358599, 3896997.5676209624], [14032938.952466376, 3896793.652420099], [14033061.091346277, 3896589.740771255], [14033195.444113696, 3896385.8326731725], [14033329.796881828, 3896181.9281274145], [14033451.935761089, 3895978.0271314387], [14033586.288528644, 3895774.129686274], [14033720.641296018, 3895584.7995237107], [14033989.34683201, 3895191.5851198323], [14034258.05236752, 3894798.383918607], [14034563.399566252, 3894405.195917184], [14034844.31899014, 3894026.5829095095], [14035149.666189065, 3893647.982137594], [14035442.799501298, 3893283.954474], [14035760.360587686, 3892919.938120377], [14036077.92167527, 3892555.9330759617], [14036407.696651513, 3892191.9393388475], [14036737.471625743, 3891857.075086648], [14036870.141786523, 3891726.198837797], [14037091.674377358, 3891507.661720512], [14037421.449352924, 3891172.81701958], [14037787.86599152, 3890867.0976043935], [14038142.06874289, 3890532.271202124], [14038178.710406706, 3890503.1563148433], [14038215.352071756, 3890459.48412038], [14038569.554822957, 3890139.2263254467], [14038923.75757426, 3889818.9772791206], [14039302.388101518, 3889513.29316919], [14039681.018628426, 3889207.617028156], [14040059.649154926, 3888931.0597660383], [14040426.065794326, 3888639.953906682], [14040816.91020996, 3888348.855272826], [14041232.18240151, 3888072.318264321], [14041623.02681568, 3887810.3418493513], [14041818.449023297, 3887679.3558354746], [14042026.085119475, 3887548.3712851256], [14042221.507326983, 3887431.941802017], [14042429.14342227, 3887300.9600144974], [14042624.565629626, 3887184.532986891], [14042844.415613849, 3887053.553961726], [14043052.051709011, 3886951.682398294], [14043259.687804861, 3886820.705972922], [14043467.323900312, 3886704.283712141], [14043687.173883341, 3886602.415180504], [14043894.809980085, 3886471.4426559876], [14044102.446075153, 3886369.576147044], [14044310.082171045, 3886267.7105223597], [14044542.146041911, 3886151.2937484276], [14044749.78213759, 3886049.4300192064], [14044957.418233024, 3885947.567174553], [14045189.4821048, 3885860.256868118], [14045397.118200412, 3885743.844137209], [14045629.182072148, 3885656.5353473793], [14045849.032055777, 3885569.2272066567], [14046056.668151285, 3885481.9197152676], [14046288.73202292, 3885380.06179798], [14046508.58200612, 3885292.755714113], [14046740.6458772, 3885205.4502798985], [14046960.495860584, 3885132.696247827], [14047192.559732666, 3885045.392004632], [14047412.409715734, 3884958.0884115743], [14047644.473587925, 3884885.335912664], [14047864.323571343, 3884812.583865854], [14048096.387442762, 3884739.8322684863], [14048316.237426357, 3884667.081122923], [14048560.515186315, 3884579.7803441263], [14048780.365169752, 3884521.5801856825], [14049012.429040425, 3884448.8303926187], [14049232.279024879, 3884390.630883527], [14049476.556783637, 3884332.431662887], [14049708.620655935, 3884288.7824363117], [14049940.684527121, 3884216.034086747], [14050172.74839862, 3884157.835731066], [14050417.026158523, 3884099.6376657546], [14050636.876141772, 3884055.989305159], [14050881.153900763, 3884012.3411064013], [14051101.003885025, 3883968.693071144], [14051345.28164453, 3883925.045197191], [14051589.55940309, 3883881.397485363], [14051809.409386702, 3883837.7499363376], [14052053.687146327, 3883794.102549823], [14052297.96490621, 3883765.00438295], [14052530.028777948, 3883721.357266986], [14052774.306537522, 3883692.2592790583], [14052994.1565203, 3883677.710312659], [14053238.434280407, 3883648.6124334624], [14053482.712039571, 3883619.514626246], [14053726.989798827, 3883604.9657499343], [14053946.839782678, 3883575.8680508113], [14054203.331430309, 3883561.3192288275], [14054435.395301264, 3883546.770424295], [14054667.459173407, 3883532.221638027], [14054911.736932652, 3883517.6728706607], [14055156.014692299, 3883503.1241203477], [14055388.078563493, 3883503.1241198485], [14055632.356323073, 3883503.1241198387], [14055876.634082645, 3883503.1241195546], [14056120.911842786, 3883488.575388423], [14056352.97571373, 3883488.575387674], [14056585.039585229, 3883503.1241202564], [14056829.31734454, 3883503.1241205744], [14057061.381216811, 3883503.1241201926], [14057305.658975704, 3883517.672870415], [14057549.936734984, 3883532.2216384728], [14057782.000606766, 3883546.7704246663], [14058026.27836623, 3883561.3192288917], [14058270.556125652, 3883575.868050909], [14058514.833885796, 3883604.9657495967], [14058734.683868349, 3883619.5146270352], [14058978.96162855, 3883648.612433189], [14059211.02550005, 3883663.161363651], [14059455.303259443, 3883692.2592792334], [14059699.581018375, 3883721.35726594], [14059919.431002565, 3883765.0043828213], [14060163.708761357, 3883794.1025502756], [14060407.98652079, 3883837.7499373327], [14060652.26428107, 3883881.397485507], [14060872.114264324, 3883925.0451974412], [14061116.392023854, 3883968.6930701793], [14061348.455895012, 3884012.3411065093], [14061580.519767078, 3884055.9893042506], [14061824.797526775, 3884099.6376657034], [14062044.647510495, 3884157.8357315855], [14062288.925269853, 3884216.0340869497], [14062508.775252802, 3884288.782436949], [14062753.053012297, 3884332.4316628594], [14062985.116884248, 3884390.6308830315], [14063204.966867786, 3884448.8303930266], [14063449.244626828, 3884521.5801856043], [14063669.09461016, 3884579.780344494], [14063901.15848242, 3884667.0811240533], [14064121.008465912, 3884739.832268733], [14064353.072337683, 3884812.5838650106], [14064585.136208225, 3884885.3359133895], [14064817.200079866, 3884958.088411405], [14065037.050063629, 3885045.3920052126], [14065269.113934992, 3885132.6962482887], [14065488.963918757, 3885205.450280084], [14065708.813902, 3885292.755713535], [14065928.663886081, 3885380.0617970554], [14066160.72775689, 3885481.9197158483], [14066380.577740904, 3885569.2272062856], [14066588.21383698, 3885656.5353471697], [14066820.277708739, 3885743.8441374716], [14067040.127691144, 3885860.2568676393], [14067259.977674901, 3885947.5671746274], [14067467.613770252, 3886049.4300197763], [14067687.463753887, 3886151.2937480435], [14067907.313738173, 3886267.7105219206], [14068114.949834043, 3886369.5761465007], [14068322.585929519, 3886471.442655181], [14068554.649800802, 3886602.4151809313], [14068750.072007738, 3886704.283711534], [14068957.708103167, 3886820.705972922], [14069165.344199639, 3886951.6823977036], [14069372.980294656, 3887053.5539613245], [14069592.83027846, 3887184.53298712], [14069788.25248586, 3887300.9600143926], [14069995.888581414, 3887431.9418018376], [14070203.524677217, 3887548.3712856653], [14070398.946884345, 3887679.3558351737], [14070606.58297968, 3887810.341848666], [14070997.427395098, 3888072.318263754], [14071400.485698925, 3888348.855272708], [14071791.330113634, 3888639.95390655], [14072157.746753618, 3888931.059766593], [14072548.59116818, 3889207.617027234], [14072915.007807814, 3889513.2931684074], [14073293.638334833, 3889818.9772791504], [14073647.841086097, 3890139.226324991], [14074014.257724732, 3890459.4841204123], [14074356.24658839, 3890779.750664216], [14074710.449339252, 3891114.584135423], [14075052.438203165, 3891463.985782468], [14075369.999290047, 3891813.397846801], [14075699.774265824, 3892162.8203282068], [14076029.549241148, 3892526.813160914], [14076334.896440182, 3892890.817300508], [14076652.457527356, 3893254.8327489593], [14076933.376951266, 3893633.4208147195], [14077238.72415068, 3894012.0211142646], [14077519.643573463, 3894405.1959178024], [14077812.776884863, 3894798.383917827], [14078081.482420994, 3895191.5851198635], [14078337.974068029, 3895584.7995231976], [14078472.326835675, 3895788.693671682], [14078594.465715563, 3895992.591370282], [14078728.818483, 3896196.4926201487], [14078850.957363801, 3896400.3974202448], [14078960.88235518, 3896604.30577156], [14079083.021235045, 3896808.2176739727], [14079205.16011431, 3897012.1331286556], [14079327.298994398, 3897230.6179148587], [14079534.935089272, 3897623.9008055353], [14079766.998961411, 3897594.7682869313], [14079999.062833289, 3897551.069644458], [14080243.34059258, 3897521.9373066192], [14080475.404464027, 3897492.8050412447], [14080707.468334954, 3897449.106778766], [14080951.746094488, 3897419.9746949435], [14081196.023853954, 3897405.408680112], [14081415.87383798, 3897376.2767037847], [14081672.365485134, 3897361.710744355], [14081904.429357013, 3897332.5788773843], [14082136.49322842, 3897318.012970849], [14082380.770987837, 3897303.44708331], [14082625.048747523, 3897303.447082881], [14082844.89873114, 3897288.8812134634], [14083101.39037848, 3897288.8812134196], [14083333.454250371, 3897274.3153610313], [14083577.73200986, 3897274.315360886], [14083809.795881303, 3897274.3153613866], [14084054.07364039, 3897274.315361214], [14084298.35139951, 3897274.3153610793], [14084530.415272055, 3897288.881213491], [14084774.693031047, 3897288.881213693], [14085018.970789962, 3897303.447082888], [14085263.248550324, 3897303.4470835133], [14085483.09853329, 3897318.012971403], [14085727.376292782, 3897332.5788774174], [14085971.654052334, 3897361.71074422], [14086203.7179239, 3897376.276704187], [14086447.995682908, 3897405.4086796427], [14086692.27344248, 3897419.9746941905], [14086912.123426246, 3897449.1067788443], [14087156.401186522, 3897492.8050409877], [14087400.678945886, 3897521.9373064945], [14087620.528929327, 3897551.0696441643], [14087864.806688221, 3897594.7682873094], [14088109.084447999, 3897623.900805951], [14088341.148319451, 3897667.5997203756], [14088573.212191245, 3897696.7324211453], [14088805.276063038, 3897740.4316074788], [14089037.339933824, 3897784.1309570693], [14089281.617693743, 3897827.830470751], [14089501.4676769, 3897900.66335366], [14089745.745436855, 3897944.3633015817], [14089965.595419774, 3898002.630152866], [14090209.873179223, 3898060.897294051], [14090441.937050942, 3898119.164725707], [14090674.000922583, 3898162.8654892095], [14090906.064793834, 3898235.7004582426], [14091125.914777994, 3898308.535880703], [14091370.19253783, 3898381.3717555343], [14091590.04252131, 3898439.6407829127], [14091822.106392259, 3898512.4774739025], [14092041.956375973, 3898585.314618505], [14092274.020247314, 3898672.7197900466], [14092493.870230613, 3898745.557932266], [14092591.581335085, 3898774.693316213], [14092616.00911053, 3898760.125616015], [14092860.286870733, 3898701.8549935655], [14093080.136854356, 3898629.0171225015], [14093312.200724699, 3898570.7471534302], [14093556.478484493, 3898527.044866997], [14093776.328468569, 3898468.775404192], [14094020.606227417, 3898425.073498839], [14094264.883987851, 3898381.3717556526], [14094484.733970387, 3898337.670176473], [14094729.011730317, 3898293.9687599814], [14094961.075602157, 3898235.7004585327], [14095193.13947348, 3898206.566416765], [14095437.417232322, 3898162.865489851], [14095669.481104298, 3898133.7316290126], [14095901.544975886, 3898090.0309733814], [14096145.8227352, 3898060.897294544], [14096377.886607243, 3898031.7636878835], [14096622.164366543, 3898017.196910956], [14096866.442125635, 3897988.063412855], [14097086.292109383, 3897973.4966916144], [14097330.569869047, 3897944.3633026695], [14097574.847628593, 3897929.7966353055], [14097806.91150033, 3897915.2299849386], [14098051.189258894, 3897900.6633545416], [14098295.467019573, 3897886.0967414593], [14098515.317002017, 3897871.5301457075], [14098759.594761733, 3897871.5301461737], [14099003.872522173, 3897871.5301457415], [14099248.15028098, 3897842.3970105853], [14099480.214152526, 3897842.3970112065], [14099724.491911555, 3897842.3970108926], [14099968.769672029, 3897871.5301465685], [14100200.833543025, 3897871.530146231], [14100445.111302666, 3897871.530146376], [14100689.389062308, 3897886.0967411445], [14100909.23904563, 3897900.6633543223], [14101153.516805617, 3897915.229985139], [14101397.794564402, 3897929.796634782], [14101629.858436095, 3897944.363301649], [14101874.136195809, 3897973.4966918174], [14102118.413954498, 3897988.0634127976], [14102338.263938468, 3898017.1969116074], [14102582.541697871, 3898031.763687157], [14102826.819457443, 3898060.897294939], [14103046.669441475, 3898090.0309742764], [14103290.947201025, 3898133.731628701], [14103535.224959875, 3898162.865489382], [14103767.288831646, 3898206.566415829], [14104011.566590969, 3898235.7004580023], [14104243.63046214, 3898293.9687599214], [14104475.694334047, 3898337.6701762597], [14104707.758205285, 3898381.3717553043], [14104952.035964744, 3898425.0734980954], [14105171.885948928, 3898468.7754048845], [14105416.163708236, 3898527.044866273], [14105636.013691971, 3898570.7471529637], [14105880.291451601, 3898629.017122684], [14106100.141434586, 3898701.8549932986], [14106344.419193909, 3898760.1256155283], [14106576.483066218, 3898818.3965284377], [14106796.33304983, 3898891.2355768005], [14107040.61080865, 3898949.5071421904], [14107260.460792817, 3899022.347006401], [14107492.524664072, 3899109.7554428596], [14107712.374646941, 3899168.0280965483], [14107944.43851916, 3899240.8693216643], [14108164.288502041, 3899313.7110006236], [14108396.35237358, 3899401.1216131775], [14108616.202357315, 3899488.5328786755], [14108848.26622925, 3899575.9447972635], [14109068.116212262, 3899648.788562357], [14109300.180084735, 3899736.201678742], [14109507.81617953, 3899823.6154470327], [14109739.8800512, 3899925.5990036307], [14109959.730035394, 3900013.0141874147], [14110167.366130177, 3900100.430024312], [14110399.430001773, 3900202.4159939], [14110607.066097446, 3900318.9724758286], [14110826.91608077, 3900406.3905996806], [14111046.766064817, 3900508.3792349175], [14111254.402160756, 3900610.368760547], [14111462.038255833, 3900726.9293071674], [14111694.102127243, 3900828.9207385355], [14111901.738223149, 3900945.483462624], [14112109.374317858, 3901062.047348597], [14112317.010413814, 3901178.6123951804], [14112536.86039789, 3901295.1786045753], [14112732.282604866, 3901411.745975727], [14112939.91870098, 3901542.8856554995], [14113147.554796439, 3901659.455495108], [14113342.977004122, 3901776.026497272], [14113562.826987848, 3901907.170262538], [14113758.249194663, 3902023.7437324016], [14113965.885290999, 3902154.8902756744], [14114161.307498729, 3902300.6103812656], [14114552.151913268, 3902562.911149271], [14114942.996328058, 3902839.790564282], [14115333.840744248, 3903131.2496635215], [14115712.471271036, 3903408.142537925], [14116091.101798624, 3903714.189658899], [14116457.518437536, 3904020.244793169], [14116823.935076432, 3904326.30794037], [14117190.351715742, 3904646.9541180977], [14117544.554466687, 3904967.6090920256], [14117874.329442928, 3905288.272862804], [14118228.532193437, 3905623.52166774], [14118558.307168506, 3905973.3567619547], [14118900.296031933, 3906323.2023288426], [14119217.857119897, 3906687.6359336833], [14119535.41820684, 3907022.924889553], [14119852.979293982, 3907387.380320937], [14120146.112605767, 3907766.426031002], [14120329.320924878, 3907999.69104288], [14120659.095900508, 3908145.484040798], [14120891.159772767, 3908247.5402225223], [14121098.79586814, 3908364.1769507537], [14121306.43196353, 3908466.2350431], [14121538.49583534, 3908568.294027048], [14121746.13193052, 3908699.514031146], [14121953.768026086, 3908801.575054541], [14122161.404121136, 3908918.217315232], [14122381.254104782, 3909034.860741095], [14122588.890200352, 3909166.0859882417], [14122784.312407838, 3909282.7318893564], [14122991.948504105, 3909399.378956402], [14123187.370711256, 3909530.6082997248], [14123407.22069531, 3909661.8391170804], [14123602.64290326, 3909778.4899717756], [14123675.926230324, 3909822.2343429914], [14123871.348437805, 3909851.3973478205], [14124103.412310153, 3909909.7235756326], [14124335.476181583, 3909953.4684379986], [14124579.753940387, 3909997.2134634694], [14124799.603924207, 3910040.9586544754], [14125043.88168351, 3910099.2858296824], [14125263.731667727, 3910143.0314019676], [14125508.009426983, 3910201.3590867375], [14125752.28718599, 3910259.6870635124], [14125972.137169205, 3910332.59744405], [14126204.201041877, 3910390.92607536], [14126424.051024832, 3910449.254999375], [14126668.328784036, 3910507.584213994], [14126888.17876787, 3910565.913720107], [14127120.242639447, 3910638.8260132936], [14127340.092622804, 3910726.3213651967], [14127584.370382065, 3910799.234659938], [14127816.434254477, 3910872.148409907], [14128036.284238072, 3910945.0626162733], [14128268.348108647, 3911017.9772767853], [14128488.198092327, 3911090.892392731], [14128720.261964472, 3911192.974320413], [14128940.111948168, 3911280.4738266575], [14129147.748043166, 3911367.973988523], [14129379.811914971, 3911440.891291154], [14129599.661898108, 3911557.559922042], [14129819.511881288, 3911645.0621605986], [14130039.361864883, 3911732.5650563217], [14130271.425736733, 3911820.068606416], [14130479.061832469, 3911936.741027793], [14130686.697928369, 3912038.8303543963], [14130918.761799408, 3912140.920572288], [14131126.397895552, 3912243.0116843027], [14131334.033990381, 3912359.6883341745], [14131566.09786253, 3912461.7813591575], [14131773.73395724, 3912578.460196206], [14131981.370053304, 3912680.5551354536], [14132189.006148996, 3912811.821370374], [14132408.856131978, 3912913.918351353], [14132604.278340138, 3913030.6017091586], [14132811.914436067, 3913161.8718802584], [14133019.55053132, 3913278.557718527], [14133214.972738754, 3913409.8306803126], [14133422.608834058, 3913526.518996874], [14133618.03104185, 3913657.7947483873], [14133837.881025733, 3913789.0719763637], [14134033.303232925, 3913920.3506824095], [14134424.147647737, 3914197.4994676104], [14134814.99206299, 3914474.654837102], [14135205.836479066, 3914737.2291565286], [14135584.467006274, 3915043.5733327474], [14135950.883645028, 3915335.3371710163], [14136329.514172366, 3915641.697056373], [14136695.93081197, 3915948.0649892436], [14137050.133562554, 3916269.0305042304], [14137416.550201891, 3916590.0048536095], [14137758.539065152, 3916925.578393816], [14138112.741816988, 3917261.161591096], [14138124.955704955, 3917290.3431960098], [14138222.66680882, 3917363.297525027], [14138576.869559862, 3917669.710695894], [14138931.072311323, 3918005.315314095], [14139297.488950564, 3918326.3374690292], [14139627.263925616, 3918676.5535388705], [14139969.25278898, 3919012.187147011], [14140286.813875556, 3919362.423825106], [14140616.588851899, 3919712.671030513], [14140934.149938418, 3920077.5230633905], [14141251.711026246, 3920442.3865206414], [14141557.058225727, 3920821.8566365796], [14141850.191537393, 3921186.743403776], [14142143.324847814, 3921580.833950086], [14142412.030383276, 3921960.3411501986], [14142705.163695073, 3922354.4578704755], [14142973.869230365, 3922748.5879268395], [14143108.221998872, 3922952.9569106484], [14143230.360877866, 3923157.3294808976], [14143364.71364529, 3923347.1072222115], [14143486.852526005, 3923566.085382383], [14143621.205292745, 3923770.468714929], [14143743.344173217, 3923974.8556338386], [14143853.269164307, 3924179.246141352], [14143975.40804453, 3924398.239951181], [14144085.333036179, 3924602.6378918802], [14144207.471916584, 3924807.039420669], [14144329.61079611, 3925011.4445379367], [14144427.321899917, 3925245.054782049], [14144537.246891692, 3925449.467591055], [14144659.38577103, 3925668.485299123], [14144757.096875027, 3925872.905545292], [14144867.021866404, 3926091.931221066], [14144976.946858248, 3926310.9610188995], [14145062.444074264, 3926529.9949393696], [14145172.369066445, 3926749.032982101], [14145257.866282122, 3926968.07514812], [14145343.36349802, 3927187.1214373973], [14145453.288489206, 3927420.7753580655], [14145538.785705116, 3927639.830169832], [14145636.496809188, 3927858.8891063603], [14145709.78013733, 3928077.9521672083], [14145831.91901659, 3928194.7874860945], [14146173.907880068, 3928516.090663645], [14146528.11063154, 3928837.402716595], [14146857.885606105, 3929187.935079745], [14147199.87446994, 3929538.4780067294], [14147529.649444804, 3929874.424892384], [14147847.210531974, 3930224.988511853], [14148164.771620288, 3930590.1701873746], [14148470.118818687, 3930955.363331506], [14148763.252130508, 3931335.176369344], [14149068.599329932, 3931715.0018159356], [14149349.518753031, 3932094.839672609], [14149642.652064433, 3932489.2998140086], [14149911.357600631, 3932869.1629744656], [14150167.84924795, 3933263.649396584], [14150302.202015493, 3933482.814306461], [14150436.554783072, 3933672.760570551], [14150558.69366279, 3933891.9331968473], [14150693.04643046, 3934081.88614829], [14150815.185310263, 3934301.0664916416], [14150937.324189857, 3934505.638541194], [14151059.463069875, 3934710.2141920165], [14151169.388061073, 3934914.793446248], [14151279.313053045, 3935133.9895019485], [14151401.45193336, 3935338.576218246], [14151499.163036935, 3935557.7802698156], [14151621.301916642, 3935762.3744509714], [14151731.226908179, 3935981.586499216], [14151841.151899958, 3936186.188145459], [14151938.863003807, 3936405.408193718], [14152195.354651982, 3936726.938415809], [14152488.487962838, 3937121.555854616], [14152757.193498401, 3937516.186704223], [14153013.685146198, 3937910.830965573], [14153148.037913712, 3938130.0835739793], [14153282.39068159, 3938320.1058491296], [14153404.52956081, 3938539.3661864866], [14153526.668440903, 3938729.3951621894], [14153661.021208057, 3938934.045228578], [14153783.16008862, 3939153.3171624425], [14153893.085079862, 3939343.356187306], [14154003.010072157, 3939562.635853141], [14154125.148951644, 3939767.300611019], [14154247.287830727, 3939986.588286231], [14154344.998935373, 3940191.260520088], [14154454.923926366, 3940410.5562055036], [14154577.062806187, 3940615.235917028], [14154686.987798307, 3940834.5396143007], [14154784.698902179, 3941053.847455504], [14154894.623893438, 3941273.159443157], [14154980.121109651, 3941477.8543701675], [14155090.046101721, 3941711.795852182], [14155175.543317659, 3941916.4985195934], [14155285.468309484, 3942150.4488461153], [14155370.965524651, 3942369.7815635717], [14155468.676628448, 3942589.1184271937], [14155554.173845042, 3942808.4594375123], [14155639.671059819, 3943042.427754295], [14155725.168275682, 3943261.7773370524], [14155810.665492112, 3943481.131068717], [14155883.948820263, 3943700.4889500365], [14155957.232147578, 3943934.475261892], [14156030.51547582, 3944153.84171713], [14156116.012691723, 3944387.8371760393], [14156189.296019575, 3944621.8373581613], [14156262.579347137, 3944855.842261796], [14156335.86267503, 3945075.226148344], [14156396.932115378, 3945309.240202267], [14156458.001554107, 3945543.2589806733], [14156531.284882791, 3945777.2824817533], [14156580.140434783, 3945996.683805485], [14156641.209874306, 3946245.343659381], [14156702.279314281, 3946479.3813347146], [14156763.348753486, 3946713.4237364368], [14156836.632082347, 3947093.7527188975], [14156922.129297458, 3947210.779532316], [14157044.268176915, 3947415.5792986215], [14157166.407056939, 3947620.3826843738], [14157300.759824937, 3947825.1896898304], [14157422.89870435, 3948030.000315225], [14157545.037583863, 3948234.8145606047], [14157667.17646415, 3948454.2624128303], [14157764.887568416, 3948659.084157569], [14157887.026448287, 3948878.5400449373], [14157996.951438919, 3949083.369290671], [14158119.090319661, 3949288.202157963], [14158216.801423091, 3949507.6699629608], [14158326.726414407, 3949727.1419266723], [14158424.437519114, 3949931.986177989], [14158534.362510465, 3950166.098329143], [14158644.287502103, 3950370.950342997], [14158729.78471748, 3950590.4386638855], [14158815.281933218, 3950809.931145622], [14158925.206925157, 3951029.427785851], [14159010.704141628, 3951248.9285875857], [14159108.415245011, 3951483.0673614168], [14159193.912461305, 3951702.576762513], [14159291.6235645, 3951936.724709892], [14159364.906892387, 3952141.6080482737], [14159450.404108545, 3952375.764874187], [14159523.687435796, 3952595.291199293], [14159621.398540307, 3952829.4572015903], [14159694.681868514, 3953048.992130602], [14159767.965195222, 3953283.1673107734], [14159841.248523328, 3953502.71084457], [14159914.531851163, 3953751.5318834265], [14159975.601291291, 3953971.084300843], [14160036.670731131, 3954205.278137325], [14160097.740171447, 3954424.8391631977], [14160171.023498941, 3954673.6800276935], [14160232.092939438, 3954893.249940999], [14160293.162378157, 3955127.4624392823], [14160342.017930873, 3955361.679679122], [14160403.087371092, 3955610.540691105], [14160451.942922773, 3955830.128382198], [14160500.798474764, 3956064.3598468173], [14160549.65402654, 3956298.596053535], [14160598.509577785, 3956547.477220151], [14160622.937354516, 3956693.880406732], [14160636.617809776, 3956751.2753614364], [14160696.220681982, 3957001.3331325892], [14160757.290121438, 3957220.947224554], [14160818.359561661, 3957469.8482368095], [14160867.215113139, 3957704.112907182], [14160916.070665386, 3957938.3823223934], [14160952.712329246, 3958172.656482196], [14161001.567881363, 3958406.9353898573], [14161050.423433455, 3958641.2190423324], [14161099.278985005, 3958890.1506262408], [14161135.920648871, 3959109.800590544], [14161172.562312365, 3959358.7422623085], [14161209.203976428, 3959593.0452026036], [14161245.845640494, 3959827.352890803], [14161270.273416875, 3960061.6653290996], [14161306.915080808, 3960310.627497995], [14161319.128968159, 3960544.9497317653], [14161343.55674493, 3960779.276716447], [14161380.198409086, 3961013.6084507345], [14161392.412296837, 3961262.5911258464], [14161404.626184527, 3961511.5791632985], [14161429.053960387, 3961745.92574739], [14161441.267848562, 3961994.924199761], [14161453.481735952, 3962214.63317148], [14161453.481736058, 3962463.6417239243], [14161465.695623817, 3962698.007616471], [14161465.695623918, 3962947.0265865847], [14161490.123400616, 3963181.402284813], [14161490.123400327, 3963430.4316741815], [14161490.12340009, 3963664.817180731], [14161490.123400327, 3963913.856991753], [14161465.695623929, 3964148.2523065866], [14161453.481736246, 3964382.6523781596], [14161453.481736366, 3964631.7076659855], [14161441.267847814, 3964866.11754977], [14161429.053960953, 3965100.5321910167], [14161429.053960415, 3965349.602961888], [14161392.412296638, 3965584.027417495], [14161380.198408043, 3965833.1086162445], [14161343.556744935, 3966067.542888706], [14161343.556744233, 3966155.4569668346], [14161331.342857195, 3966316.6345174764], [14160989.35399289, 3968675.9452985157], [14160366.445706693, 3971035.738514718], [14159609.18465247, 3973249.3995169974], [14158717.57082961, 3975478.1498437845], [14157471.754256403, 3977560.6613335563], [14155041.190549271, 3980831.8443643325], [14152329.70741956, 3983619.6844641836], [14151987.71855616, 3983825.1309771356], [14148885.391009744, 3986188.029616028], [14146809.030054526, 3987362.3212974994], [14144659.385770556, 3988331.2022260944], [14142448.672047747, 3989109.3020907817], [14140018.108340502, 3989667.217435894], [14137673.041849403, 3990078.3302842528], [14136695.930811903, 3990151.744841114], [14135095.911487218, 3991458.6024279883], [14131847.017286118, 3993896.5093908194], [14128537.053643916, 3995644.4841260226], [14126998.103759484, 3996261.479934003], [14128940.111947352, 3996599.372644142], [14130882.120135639, 3997025.425458683], [14132824.128323132, 3997436.8018056713], [14133581.38937842, 3997642.4955095113], [14135169.19481456, 3997995.1218662434], [14137111.20300309, 3998215.51884181], [14138979.92786301, 3998421.2265061126], [14140848.652722916, 3998685.713208066], [14142790.66091092, 3998906.123450371], [14144732.669099433, 3999170.621330323], [14146601.39395857, 3999317.5672330167], [14148543.402146455, 3999596.769632924], [14150412.12700758, 3999875.9788301843], [14152329.70741913, 4000081.7162713646], [14154271.715607245, 4000302.153339294], [14155041.190549305, 4000360.9372721342], [14156983.19873771, 4000640.165071943], [14158912.993037248, 4000919.399671307], [14160781.717897676, 4001125.1558314012], [14162797.009413889, 4001345.612956999], [14164580.237058092, 4001610.1671023793], [14166522.245245533, 4001815.935658166], [14167120.725757152, 4001933.519348061], [14167987.911802663, 4002036.4060658], [14173362.02251159, 4002697.8427284476], [14178760.46447004, 4003210.7735754605], [14183572.832858339, 4003668.0188718894], [14206901.358889744, 4005932.0826477716], [14210370.103074845, 4006505.5204501776], [14216550.330390768, 4008623.077985625], [14220983.971725512, 4010961.6694398993], [14225038.982532345, 4014271.8137577237], [14230608.515449705, 4021896.1459267535], [14237533.789931284, 4030556.9660328445], [14248477.43355596, 4044607.966866741], [14248819.422419427, 4044725.9920548676], [14248894.821504684, 4044751.2460003477], [14332384.43799789, 4072715.0099619655], [14339458.685080042, 4075084.437250298], [14339432.408530401, 4075075.6362608722], [14347742.667809354, 4070231.176358625], [14347779.925183792, 4070274.6906909533], [14337204.573632397, 4057923.327535437], [14332384.437499993, 4053484.2600356997], [14325516.027051724, 4047158.849973145], [14297318.800024424, 4007338.115534628], [14296205.605126167, 4000638.8920895318], [14289147.949369663, 3995496.544127517], [14248894.821533248, 3948046.126943193], [14240260.255669821, 3937867.6942487117], [14234601.398877025, 3931197.0296311476], [14233310.092817476, 3925646.324512699], [14227187.520809716, 3914566.141529868], [14215309.731123524, 3889770.2929695556], [14213829.181949753, 3888669.1786231115], [14154273.254398886, 3837917.649432496], [14085910.608311396, 3787395.72363925], [14026312.49388151, 3787395.72363925]]]]}}]} \ No newline at end of file diff --git a/frontend/src/data/zones/특정어업수역Ⅲ.json b/frontend/src/data/zones/특정어업수역Ⅲ.json new file mode 100644 index 0000000..186078b --- /dev/null +++ b/frontend/src/data/zones/특정어업수역Ⅲ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed3", "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\u2162", "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": [[[[13817590.293393573, 4163976.6556012244], [13935718.55954324, 4163976.6556012244], [13935619.01320107, 4163881.1438622964], [13923844.505073382, 4152583.8553656973], [13918348.255484505, 4147059.076131751], [13915673.414018149, 4144230.73373971], [13914293.244677586, 4142310.8465966308], [13912412.305929314, 4139320.052336007], [13910836.71437977, 4135854.113539339], [13910055.02554902, 4132998.86846704], [13908345.081232697, 4127825.5937968497], [13907722.172945773, 4125120.982583305], [13907392.39797139, 4121971.379578588], [13905548.100886794, 4109218.5915996092], [13903996.937114129, 4098184.7930658716], [13902433.559452614, 4086362.162986858], [13899270.162467, 4063581.503750727], [13896473.182120506, 4043914.5936157014], [13897865.565350175, 4034226.54128085], [13899148.023587234, 4029334.0367157785], [13901432.020639004, 4024841.2655827063], [13904314.498200562, 4020453.314503754], [13908809.208975887, 4016346.6591829173], [13913084.069767442, 4012962.3532931497], [13916381.819520816, 4010770.4459119253], [13921743.716342429, 4008961.335245877], [13958947.219114931, 4002242.1822701376], [13979370.589551304, 3998601.864321506], [14011454.723518057, 3992883.0996783567], [14012407.40678011, 3992706.863639312], [14013372.303930415, 3992530.6303036474], [14014324.987193013, 3992369.0854542954], [14015277.670454111, 3992192.8573017977], [14016230.35371629, 3992031.3172021704], [14017195.250866242, 3991855.094230278], [14018147.934129067, 3991678.873962591], [14019100.617390765, 3991502.656396452], [14020053.300652908, 3991341.126002646], [14021018.197802687, 3991179.5978787914], [14021909.811624419, 3991018.0720274393], [14022813.639334908, 3990856.5484449966], [14023766.32259687, 3990650.976261227], [14024731.219746705, 3990445.407755316], [14025525.122466035, 3990298.575359531], [14026477.805727897, 3990063.6474289736], [14027442.702878024, 3989828.724300673], [14027845.761180786, 3989740.629365874], [14028798.444442954, 3989505.7128408654], [14029763.341592584, 3989270.80111587], [14030716.024854451, 3989050.57573502], [14031668.70811654, 3988815.6733126394], [14033073.30523366, 3988478.0094901477], [14033024.449681701, 3988213.757764475], [14033000.021905882, 3987949.512112862], [14032963.380242558, 3987582.5143390666], [14032938.952466676, 3987332.9625437283], [14032914.524689825, 3987098.09521391], [14032902.310802005, 3986863.232680407], [14032877.883026825, 3986613.69649413], [14032877.883025693, 3986378.843854627], [14032841.241362942, 3986143.996010515], [14032841.241361871, 3985894.47543004], [14032829.027473792, 3985659.6374763353], [14032829.027474709, 3985410.127403529], [14032829.027473787, 3985175.2993370066], [14032829.02747455, 3984940.4760656278], [14032816.813586919, 3984690.9815914035], [14032829.027474267, 3984456.1682059844], [14032829.027474323, 3984221.3596118884], [14032829.02747492, 3983971.8807323813], [14032841.24136266, 3983737.082022118], [14032841.24136211, 3983487.613641918], [14032865.66913836, 3983252.8248136323], [14032877.88302635, 3983003.3669318845], [14032902.310802022, 3982783.2615266712], [14032914.52469013, 3982533.813822452], [14032938.952465724, 3982299.044452602], [14032963.38024159, 3982064.2798706265], [14032975.59412962, 3981814.847749137], [14033012.23579437, 3981594.76507107], [14033036.663569707, 3981345.3431236055], [14033073.305233562, 3981110.597991455], [14033097.733010307, 3980861.18653368], [14033146.588561533, 3980641.122086566], [14033171.016337542, 3980391.7207993036], [14033219.871889306, 3980171.665326035], [14033268.727441864, 3979922.2742063804], [14033305.369105555, 3979687.5580867655], [14033354.224656736, 3979452.846750307], [14033403.080208756, 3979218.140198897], [14033464.149649367, 3978968.769728522], [14033500.79131351, 3978748.741443538], [14033561.860753022, 3978514.0492399754], [14033622.930192148, 3978279.3618192296], [14033683.999632282, 3978044.6791785043], [14033745.06907279, 3977824.6685465015], [14033806.138512583, 3977575.3282415215], [14033867.207951993, 3977355.3265721146], [14033940.49128036, 3977120.6627559136], [14034013.774608184, 3976886.0037188698], [14034074.84404724, 3976651.349460947], [14034148.127375823, 3976431.365434353], [14034233.624591732, 3976211.385607544], [14034306.907918751, 3975976.745086343], [14034380.191247718, 3975756.7739374605], [14034465.688463435, 3975522.1426732005], [14034563.399566704, 3975302.1802014867], [14034636.682894846, 3975067.5581912696], [14034722.18011089, 3974862.2678504367], [14034819.89121484, 3974627.654795317], [14034905.388430584, 3974407.7093927874], [14035015.313421464, 3974187.7681863704], [14035100.810637798, 3973967.831176343], [14035198.52174194, 3973747.8983624075], [14035296.232845142, 3973527.9697442516], [14035406.157837268, 3973322.706818716], [14035503.868941093, 3973102.7863105624], [14035564.938380616, 3972970.836018713], [14035430.585612448, 3972882.8699968406], [14035051.955086311, 3972589.6547698523], [14034685.538446855, 3972281.7868043673], [14034306.907918967, 3971988.5868595946], [14033928.277392257, 3971680.7349405857], [14033561.860752953, 3971358.232219437], [14033207.658001786, 3971050.3971231086], [14032865.66913857, 3970727.9120247746], [14032511.46638746, 3970376.1203815714], [14032169.477523897, 3970053.654131018], [14031851.916436315, 3969701.8830453446], [14031522.141461194, 3969350.1226848057], [14031192.36648596, 3969013.029068952], [14030874.80539861, 3968646.634131671], [14030569.4581991, 3968265.5957370186], [14030276.324888123, 3967899.224527907], [14029970.977688076, 3967518.2108068704], [14029677.844376301, 3967137.2096621543], [14029409.138841135, 3966756.2210914562], [14029140.433306115, 3966360.592421976], [14028871.727769978, 3965964.977308897], [14028737.375003058, 3965759.848882349], [14028615.236123221, 3965554.724099639], [14028480.883355275, 3965349.6029609945], [14028358.744475357, 3965144.4854657836], [14028224.391707785, 3964939.371614421], [14028102.252828015, 3964734.2614048333], [14027980.11394778, 3964529.1548381275], [14027857.975069236, 3964324.051914157], [14027760.26396438, 3964104.302822176], [14027638.125085097, 3963899.207441546], [14027528.200093549, 3963679.4664326143], [14027406.061214069, 3963474.378595102], [14027308.350109257, 3963254.6456658943], [14027198.425118413, 3963049.5653696535], [14027088.500126269, 3962815.1923459424], [14027003.00291053, 3962610.11984976], [14026893.077918677, 3962390.4033569284], [14026795.366814636, 3962170.6910431627], [14026697.655711418, 3961965.629985329], [14026599.944607599, 3961731.278946997], [14026502.233502936, 3961511.5791640463], [14026416.736287547, 3961291.883556766], [14026331.239071513, 3961072.192127874], [14026245.741855916, 3960837.859204179], [14026148.030752573, 3960603.531032333], [14026050.319648793, 3960515.6591924117], [14025696.116896924, 3960208.113014822], [14025354.12803417, 3959885.930555696], [14024987.711394375, 3959549.113039522], [14024645.722530954, 3959226.948944662], [14024291.51977964, 3958890.150626061], [14023961.74480434, 3958538.7193601266], [14023644.183716903, 3958172.6564824986], [14023314.408742214, 3957835.8888684427], [14023009.061542125, 3957469.8482367387], [14022703.714343427, 3957103.8191892453], [14022398.367143923, 3956723.1612672796], [14022117.447720738, 3956342.515870507], [14021812.100520544, 3955947.2435213765], [14021543.394986114, 3955566.6236525774], [14021250.261673959, 3955171.377811075], [14020993.770026248, 3954776.145468736], [14020859.417258942, 3954556.577778357], [14020737.27837941, 3954366.2891494785], [14020602.925611844, 3954176.0036486983], [14020480.786732143, 3953956.4473441243], [14020370.861740284, 3953751.53188359], [14020248.722860433, 3953546.620051208], [14020126.583980937, 3953341.7118456624], [14020004.445100708, 3953122.171365647], [14019882.306221237, 3952917.270673172], [14019784.595117368, 3952697.7382422695], [14019662.456236959, 3952492.8450626153], [14019552.531245224, 3952273.3206794215], [14019442.606254242, 3952068.435010681], [14019344.89515069, 3951834.2844000966], [14019234.97015812, 3951629.406499703], [14019137.259054784, 3951424.532224624], [14019027.33406315, 3951190.394634004], [14018941.836846726, 3950985.528125728], [14018831.911855552, 3950751.3994105365], [14018734.200752072, 3950546.540667035], [14018648.703535682, 3950312.4208263634], [14018563.206319205, 3950092.937773037], [14018465.495216299, 3949858.827100896], [14018379.99800022, 3949639.352642628], [14018306.71467212, 3949405.251136331], [14018209.003568063, 3949185.785271415], [14018135.72024025, 3948951.692931064], [14018062.43691314, 3948732.2356575206], [14017989.153585482, 3948498.1524819196], [14017915.87025767, 3948278.7037986964], [14017842.586929433, 3948044.6297840844], [14017757.089713955, 3947825.1896894635], [14017696.02027415, 3947591.124836192], [14017622.736946309, 3947371.6933298754], [14017561.667506203, 3947123.0093109384], [14017500.598066194, 3946888.958639055], [14017451.742514167, 3946654.9126930023], [14017390.673074305, 3946420.8714734227], [14017329.603633871, 3946157.5807487303], [14017195.25086627, 3945791.9091077414], [14017109.753650622, 3945557.885310957], [14017036.470322493, 3945338.4922908037], [14016963.186994748, 3945104.477646201], [14016889.903667673, 3944885.0932069574], [14016816.620339664, 3944651.0877120267], [14016743.337011732, 3944431.7118500136], [14016682.267571904, 3944197.715505999], [14016621.198132234, 3943963.7238824223], [14016547.914804032, 3943729.736980628], [14016486.845363976, 3943510.378546276], [14016425.775924595, 3943261.7773378296], [14016364.70648495, 3943042.427753915], [14016315.850932516, 3942793.8365748394], [14016254.781492097, 3942574.4958398864], [14016193.712052723, 3942325.9146883073], [14016157.070389032, 3942106.582801053], [14016108.214837069, 3941858.0116748144], [14016059.359284986, 3941624.0672444003], [14016034.931508765, 3941390.1275309804], [14015986.075957134, 3941141.571752944], [14015937.22040512, 3940922.2622533315], [14015912.792629696, 3940673.7164975833], [14015876.150965236, 3940439.7959427596], [14015839.509301143, 3940191.260519465], [14015802.867637865, 3939971.9689781107], [14015790.653749412, 3939723.443572689], [14015754.012085313, 3939489.542170439], [14015729.584309753, 3939241.0270946748], [14015717.37042194, 3939007.1354134823], [14015680.728758091, 3938758.630664511], [14015668.514870305, 3938510.131235958], [14015656.300982298, 3938290.871450771], [14015644.087093962, 3938042.3820334156], [14015644.087093726, 3937808.5144985737], [14015619.659317939, 3937560.035403366], [14015619.659317758, 3937326.1775837634], [14015607.445430323, 3937077.708810477], [14015607.445430165, 3936843.86070301], [14015607.44543046, 3936610.017304439], [14015607.445429819, 3936361.563852228], [14015619.659318132, 3936127.730163849], [14015619.65931785, 3935879.2870282456], [14015619.659318443, 3935864.672891335], [14015644.087093579, 3935645.4630488665], [14015644.087094065, 3935397.030227534], [14015656.300982174, 3935163.215955291], [14015668.51486993, 3934914.7934458014], [14015680.728758322, 3934680.988878479], [14015717.370422252, 3934432.576680492], [14015729.584309453, 3934213.393858009], [14015754.012085263, 3933964.991656822], [14015790.653749326, 3933716.5947647644], [14015802.867637549, 3933482.8143059933], [14015839.509301828, 3933234.4277204718], [14015863.937076895, 3933000.6569608934], [14015912.79262938, 3932766.8909023306], [14015937.220405562, 3932533.1295448784], [14015986.075957565, 3932299.3728892817], [14016010.503732808, 3932065.6209347122], [14016059.359284472, 3931817.2646324276], [14016108.214836905, 3931583.522371577], [14016169.284276439, 3931349.784810239], [14016205.925940333, 3931101.4438007176], [14016254.781492744, 3930882.3237842843], [14016315.85093268, 3930633.992758967], [14016364.706484258, 3930414.8815508736], [14016425.77592429, 3930166.5605083024], [14016486.845364062, 3929947.45810794], [14016547.91480444, 3929699.147045077], [14016621.19813177, 3929480.053450812], [14016682.267571429, 3929246.358166313], [14016743.337011438, 3929012.667577336], [14016816.620339306, 3928793.5869140886], [14016889.903667254, 3928545.3004834815], [14016963.186994506, 3928326.228621952], [14017036.470323365, 3928092.5565168317], [14017109.753650554, 3927873.4931812496], [14017195.250866888, 3927639.8301689653], [14017280.748082507, 3927420.7753582653], [14017354.031410536, 3927187.121436862], [14017451.742513658, 3926982.678106103], [14017537.239729747, 3926749.032981505], [14017622.736945918, 3926529.994938449], [14017720.448049057, 3926296.358903417], [14017818.159152932, 3926091.931220958], [14017915.870256566, 3925858.3039796036], [14018001.367473086, 3925653.883990288], [14018111.292464526, 3925420.265541152], [14018209.00356852, 3925201.2525036694], [14018318.928560698, 3924996.844052992], [14018428.853552194, 3924763.2387903365], [14018526.564656094, 3924558.8380302167], [14018636.489648066, 3924339.8411988374], [14018758.628527954, 3924135.447872815], [14018856.339631462, 3923916.459004483], [14018978.478511153, 3923712.073110865], [14019100.617391173, 3923493.0922050807], [14019222.756270064, 3923288.713741847], [14019344.895150287, 3923084.338865018], [14019467.034029689, 3922879.9675755682], [14019589.172909968, 3922661.0023167264], [14019723.525677131, 3922471.235755037], [14019833.45066894, 3922252.2781808744], [14019980.017325126, 3922062.518278752], [14020114.370092534, 3921872.7614680813], [14020383.075627644, 3921464.06499056], [14020651.781162936, 3921084.5739564244], [14020932.700586699, 3920690.500196857], [14021225.833898693, 3920311.0343588293], [14021518.967209466, 3919931.5808786163], [14021824.31440906, 3919552.1397545147], [14021873.169961166, 3919508.3588811457], [14022007.522728465, 3919158.1178169283], [14022117.447720494, 3918939.222496516], [14022215.158823974, 3918720.331287067], [14022312.869927935, 3918486.851860621], [14022410.581031999, 3918282.5611990155], [14022520.506023144, 3918063.6823207946], [14022618.217127495, 3917844.807552783], [14022728.14211945, 3917625.9368926086], [14022838.06711147, 3917421.6613179413], [14022960.205990292, 3917202.798602683], [14023057.917095127, 3916998.53044049], [14023180.055974487, 3916779.675667872], [14023302.194854327, 3916575.4149192595], [14023424.333733585, 3916356.568086974], [14023546.472613554, 3916166.904154751], [14023656.397605527, 3915948.0649889517], [14023778.536485165, 3915758.4077001447], [14023912.889252687, 3915539.5761998114], [14024035.028132746, 3915335.337170445], [14024303.73366759, 3914941.4577129018], [14024560.225315124, 3914547.5915551204], [14024853.358626463, 3914153.7386953356], [14025134.278049812, 3913759.899131608], [14025427.411361214, 3913380.6587829227], [14025720.544672925, 3913001.430759424], [14026025.891872894, 3912636.800052067], [14026272.928939708, 3912341.809856742], [14026312.49388151, 3787395.72363925], [13961312.04903013, 3787395.72363925], [13947389.033479117, 3802232.5362782693], [13947389.033479117, 3802232.5362782683], [13822584.485054709, 3935228.2723445967], [13818455.7465445, 3939627.988734125], [13804540.810181938, 4028802.026185181], [13817531.794596978, 4163881.1427735523], [13817531.794716273, 4163881.144013962], [13817590.293393573, 4163976.6556012244]]]]}}]} \ No newline at end of file diff --git a/frontend/src/data/zones/특정어업수역Ⅳ.json b/frontend/src/data/zones/특정어업수역Ⅳ.json new file mode 100644 index 0000000..1ce6f88 --- /dev/null +++ b/frontend/src/data/zones/특정어업수역Ⅳ.json @@ -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]]]]}}]} \ No newline at end of file diff --git a/frontend/src/utils/fishingAnalysis.ts b/frontend/src/utils/fishingAnalysis.ts index ad93e24..fcc1c45 100644 --- a/frontend/src/utils/fishingAnalysis.ts +++ b/frontend/src/utils/fishingAnalysis.ts @@ -2,6 +2,14 @@ // 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류 import type { Ship } from '../types'; +import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; +import { point, multiPolygon } from '@turf/helpers'; +import type { Feature, MultiPolygon } from 'geojson'; + +import zone1Data from '../data/zones/특정어업수역Ⅰ.json'; +import zone2Data from '../data/zones/특정어업수역Ⅱ.json'; +import zone3Data from '../data/zones/특정어업수역Ⅲ.json'; +import zone4Data from '../data/zones/특정어업수역Ⅳ.json'; /** * 중국 허가 업종 코드 (허가번호 접두사) @@ -43,14 +51,57 @@ const GEAR_META: Record { + const feat = data.features[0]; + const multiCoords: number[][][][] = feat.geometry.coordinates.map( + (poly: number[][][]) => poly.map( + (ring: number[][]) => ring.map(([x, y]: number[]) => epsg3857ToWgs84(x, y)), + ), + ); + return multiPolygon(multiCoords).geometry + ? { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multiCoords } } + : multiPolygon(multiCoords) as unknown as Feature; +} + +export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE'; + +export interface FishingZoneInfo { + zone: FishingZoneId; + name: string; + allowed: string[]; +} + +/** + * 특정어업수역 Ⅰ~Ⅳ 폴리곤 (WGS84 변환 캐시) + */ +const ZONE_POLYGONS: { id: FishingZoneId; name: string; allowed: string[]; geojson: Feature }[] = [ + { id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) }, + { id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) }, + { id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) }, + { id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) }, +]; + +/** + * 특정어업수역 폴리곤 기반 수역 분류 + */ +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: [] }; +} /** * 업종별 허가 기간 (월/일) diff --git a/prediction/algorithms/location.py b/prediction/algorithms/location.py index 44ccf86..e2dfddd 100644 --- a/prediction/algorithms/location.py +++ b/prediction/algorithms/location.py @@ -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', diff --git a/prediction/data/zones/특정어업수역Ⅰ.json b/prediction/data/zones/특정어업수역Ⅰ.json new file mode 100644 index 0000000..f0454ef --- /dev/null +++ b/prediction/data/zones/특정어업수역Ⅰ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed1", "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\u2160", "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": [[[[14612352.95900835, 4323569.555957972], [14550748.752774281, 4260105.381317261], [14544627.066163512, 4252568.169285575], [14439940.71936106, 4252568.1692174645], [14440259.902536998, 4254382.900417306], [14440565.249736432, 4256577.976660408], [14441200.37191117, 4258322.323996074], [14442128.627396706, 4261947.246114864], [14442446.188484557, 4263842.912458916], [14443081.310658677, 4265407.837348879], [14443838.571713375, 4268086.787104008], [14444461.480000762, 4270299.674084436], [14445414.16326165, 4272528.068283305], [14446488.98540456, 4275811.262361098], [14447111.893690424, 4279125.58051958], [14447441.668665636, 4283375.409280503], [14447441.668666717, 4285908.011073243], [14447747.015866045, 4287008.670616378], [14449298.17963799, 4289692.937620907], [14451325.68504222, 4294897.478106175], [14452583.715503562, 4299470.4800484385], [14452583.715504179, 4299666.724555172], [14452864.634927392, 4301297.200756734], [14452803.565487338, 4303864.187591692], [14452864.634926995, 4306733.892102277], [14452681.426607274, 4309982.105854211], [14452229.512752429, 4313034.803597244], [14451289.043378348, 4315906.938650241], [14450165.365684237, 4319883.836509038], [14448650.843575954, 4323816.808519151], [14447172.963130116, 4326268.076469068], [14445646.22713406, 4328477.720500556], [14443166.807874365, 4331384.242207033], [14440455.324744267, 4333928.090897115], [14438366.74990012, 4335578.885027033], [14435545.341778146, 4337381.416707463], [14435212.858055448, 4337568.367409996], [14433713.258582642, 4338411.570116835], [14431881.17538587, 4339153.947573591], [14430305.583837628, 4339729.704068462], [14430281.156061549, 4340669.162281877], [14432430.800344449, 4344124.648762426], [14433664.40302976, 4347050.554454509], [14434299.525204673, 4348582.044935347], [14435398.775122227, 4352055.241843149], [14436168.250064695, 4355377.820309184], [14436473.59726421, 4359156.794673912], [14436632.377808044, 4361358.014828757], [14437096.505551115, 4363255.98064219], [14438036.97492467, 4367341.541713113], [14438354.536012871, 4371595.823810485], [14438183.541581359, 4375213.2915699165], [14437218.644430902, 4379455.492532148], [14436754.516687542, 4381676.095802414], [14437218.644430652, 4383410.311934654], [14438940.802635401, 4387670.983955953], [14440333.18586435, 4392085.61379955], [14440821.741384165, 4395862.331199512], [14440968.308038985, 4399000.459396788], [14441114.874694768, 4403419.77764905], [14441273.655239271, 4409426.891672738], [14440772.885832038, 4413972.594613021], [14439991.197000964, 4416642.956092686], [14438891.94708353, 4419329.253028348], [14437621.702733777, 4422642.269553811], [14436046.111184767, 4426582.592213162], [14435117.855699727, 4428767.199310407], [14434946.861267168, 4430203.479647634], [14434946.86126783, 4432709.795922216], [14434470.519636003, 4435537.753788483], [14434617.086292291, 4437433.672085769], [14434617.08629216, 4439314.635756483], [14434922.433492135, 4440431.137925814], [14435545.341778962, 4443276.456208943], [14435862.902865293, 4448617.316293129], [14435374.347346198, 4453195.1551512005], [14434433.877972556, 4456978.343560426], [14433493.582733309, 4459337.341378763], [14433493.408598367, 4459337.778245751], [14432540.725337084, 4461222.640432275], [14430134.5894049, 4464840.066412149], [14429071.98115172, 4466066.593934474], [14429377.32835093, 4468244.031514878], [14429487.253342932, 4471664.436166196], [14429487.253342314, 4474871.114707357], [14428962.056159778, 4478201.569995016], [14428339.14787256, 4480581.111676515], [14427423.10627465, 4482961.1914115725], [14426482.636900885, 4485234.2862444], [14424332.992617503, 4488229.992534473], [14422684.117740594, 4490519.594851016], [14421218.451183844, 4492071.890874874], [14420192.484594172, 4493424.573415368], [14418641.32082132, 4495484.674666911], [14415856.554362778, 4498806.286043997], [14415123.721083565, 4501082.812019949], [14413291.637888283, 4505437.2749514375], [14411545.051908031, 4508454.147134834], [14409053.418760045, 4511333.299393252], [14407502.25498812, 4512996.485903551], [14405743.455119297, 4516308.245790257], [14404192.291346865, 4519020.04815391], [14402445.705366325, 4520776.931720719], [14401199.88879329, 4522117.909890488], [14401163.247128874, 4522441.620044785], [14400674.691610316, 4526804.977928812], [14399135.74172542, 4530630.196696397], [14397889.92515128, 4533530.893432845], [14396448.686370509, 4536216.299453886], [14394482.250406215, 4538609.053820163], [14393969.267111044, 4540878.818581772], [14393248.647720557, 4543272.6434066], [14391697.483947853, 4546578.5701886], [14389950.897967545, 4549576.439029636], [14387996.67589144, 4552080.475715166], [14386433.298231108, 4553935.706287525], [14384686.712251175, 4555296.4190314105], [14384063.803963741, 4555698.481870257], [14382817.987391062, 4558497.888716806], [14382023.990793852, 4559433.839678707], [14379825.584836785, 4562025.286767265], [14377993.501640612, 4563882.293207642], [14375709.504589545, 4565538.404034689], [14373230.085329972, 4569687.579878523], [14370848.377174843, 4572490.760700769], [14369101.79119466, 4574132.75433843], [14367978.113501683, 4575372.167021386], [14367025.430239363, 4578161.387517195], [14365584.191457871, 4580563.818041922], [14364155.16656508, 4582966.805917671], [14362591.788904771, 4585230.77563233], [14360136.797420906, 4586983.366182029], [14359501.67524686, 4589031.016082314], [14357755.089266032, 4591730.810433944], [14356924.544884088, 4593049.931812809], [14355397.808887746, 4596589.112554951], [14353321.447931753, 4599694.655522917], [14351355.011967713, 4602086.560165967], [14350548.895361273, 4602909.876326975], [14349058.80102805, 4604059.521970236], [14348362.609413402, 4605286.987737952], [14347006.867848393, 4607571.395078686], [14345260.281868543, 4610058.400036733], [14344344.240270587, 4614925.339107906], [14344178.729650684, 4615426.598601238], [14406264.563155375, 4615426.598601238], [14471145.302268442, 4615426.598601238], [14489820.50078106, 4579817.049246806], [14657058.866457367, 4579819.039140932], [14657058.866471501, 4498513.035634587], [14653280.330118885, 4484660.955595197], [14653257.89496764, 4484604.528273547], [14653111.328311926, 4484251.265963233], [14652952.547767457, 4483867.298646529], [14652805.981111629, 4483498.703204842], [14652793.767223712, 4483437.271887497], [14652732.697783664, 4483283.695161308], [14652573.91724023, 4482899.763155043], [14652463.992248593, 4482531.201607391], [14652317.425592588, 4482147.2970551355], [14652183.072824666, 4481778.761862163], [14652085.361720739, 4481394.884757528], [14651963.222840805, 4481011.021654676], [14651853.297849245, 4480627.172552061], [14651731.158969458, 4480243.337446124], [14651645.661754325, 4479828.811251124], [14651560.164537868, 4479460.357225606], [14651450.239546517, 4479061.213248133], [14651376.956218421, 4478677.435232205], [14651303.672890497, 4478278.320934065], [14651230.38956299, 4477894.571451181], [14651181.534010744, 4477495.486823346], [14651120.464571165, 4477096.417317753], [14651059.395131014, 4476712.710899764], [14650998.325691152, 4476313.671051835], [14650996.99554685, 4476290.271738564], [14645926.917379338, 4457703.411105691], [14630424.731020536, 4444761.216899179], [14601399.121065676, 4420528.823331998], [14513278.61218791, 4420528.823356817], [14513278.612126667, 4323569.5559741575], [14612352.95900835, 4323569.555957972]], [[14531705.281810218, 4513797.373626424], [14531693.067921922, 4513597.145949486], [14531680.854034334, 4513381.520424651], [14531680.854033662, 4513196.702081734], [14531656.42625728, 4512981.084804853], [14531631.99848197, 4512596.064997208], [14531631.998481335, 4512395.860290817], [14531631.998482231, 4512180.259503853], [14531619.784594133, 4512010.861999429], [14531619.784593917, 4511795.269138], [14531619.784594417, 4511579.680716071], [14531619.784594513, 4511394.89417161], [14531619.784594564, 4511194.712427556], [14531631.998481907, 4510979.136366602], [14531631.99848219, 4510794.36041791], [14531631.998481516, 4510578.792596463], [14531656.426257836, 4510193.861093197], [14531680.854033632, 4509993.7023007], [14531680.854033832, 4509793.547333112], [14531693.067922262, 4509593.396189629], [14531705.28180977, 4509393.248870632], [14531741.923473405, 4509193.105373775], [14531754.137361716, 4508992.965701181], [14531778.565137729, 4508608.0924608875], [14531827.420689235, 4508207.83928627], [14531888.490129804, 4507807.60139917], [14531937.345681723, 4507407.378797934], [14531986.201233227, 4507022.563787677], [14532071.698448502, 4506622.371163723], [14532144.981777469, 4506222.193819813], [14532218.26510484, 4505822.031752334], [14532291.54843238, 4505437.274939068], [14532389.259536454, 4505052.532246742], [14532486.970639894, 4504667.803672876], [14532584.681744624, 4504283.089215654], [14532682.3928476, 4503898.388874675], [14532804.5317276, 4503498.31548994], [14532816.74561534, 4503467.54124596], [14532841.173391888, 4503236.737298468], [14532890.028944094, 4502836.689154722], [14532951.098383617, 4502436.656270566], [14533036.595599566, 4502036.638641951], [14533085.451150997, 4501652.020693331], [14533170.94836721, 4501252.032985861], [14533256.445583222, 4500882.827099174], [14533329.728910444, 4500467.486007207], [14533427.440014655, 4500082.925582424], [14533512.937230268, 4499698.379251896], [14533537.365005817, 4499636.85314637], [14533635.07610988, 4499313.847011714], [14533720.573326208, 4498929.328863146], [14533842.71220557, 4498544.824803407], [14533964.851085538, 4498160.334828932], [14534086.989965722, 4497775.8589393], [14534221.342733555, 4497406.77533439], [14534343.481613029, 4497022.3270443855], [14534490.048267843, 4496653.269931789], [14534636.614923632, 4496284.225792352], [14534795.395467635, 4495899.818607236], [14534941.962123044, 4495530.800950158], [14535088.528779598, 4495161.796260659], [14535271.737098787, 4494808.178934617], [14535442.73153039, 4494454.573515013], [14535625.939850742, 4494085.606640488], [14535821.362058103, 4493732.025548209], [14535992.356489455, 4493363.084053765], [14536187.77869705, 4493024.899068225], [14536370.987016352, 4492671.353679356], [14536578.623112632, 4492333.190962355], [14536798.473096136, 4491979.668849295], [14537006.10919119, 4491656.8981793765], [14537225.959175108, 4491303.398821744], [14537421.381382758, 4490980.648924496], [14537653.44525381, 4490642.540617242], [14537909.936901638, 4490319.811016954], [14538129.786885347, 4489997.091326332], [14538374.0646448, 4489659.014660098], [14538618.342404164, 4489351.681672938], [14538850.406276468, 4489044.357672096], [14539131.325698882, 4488721.677141822], [14539363.389571583, 4488429.736623869], [14539644.308994643, 4488122.43957564], [14539900.800642235, 4487815.151508973], [14539986.297858382, 4487722.966840681], [14540010.725633759, 4487692.23879659], [14540267.217281476, 4487400.32686548], [14540511.495040976, 4487077.696790041], [14540780.200575706, 4486770.43925529], [14541073.333887419, 4486493.915150635], [14541329.825535271, 4486186.674672224], [14541598.531071173, 4485910.165917298], [14541867.23660634, 4485618.303450893], [14542148.156029876, 4485326.449084601], [14542453.503228514, 4485049.962943723], [14542734.422652928, 4484773.484071729], [14543039.76985148, 4484512.371808684], [14543332.903163565, 4484251.266027703], [14543638.250362527, 4483974.808145135], [14543943.59756212, 4483713.715705612], [14544248.944761822, 4483467.987564921], [14544566.505848715, 4483222.26516165], [14544871.85304831, 4482976.548497614], [14545213.841912381, 4482746.194336815], [14545519.189110842, 4482515.845218762], [14545836.750198007, 4482254.788980392], [14546190.952949973, 4482024.450618634], [14546508.514037313, 4481824.828116437], [14546838.28901309, 4481609.854272898], [14547180.27787654, 4481394.88481907], [14547510.052850928, 4481179.919756588], [14547876.469490254, 4480995.667482875], [14548218.458354343, 4480796.06449436], [14548560.447216801, 4480596.465289407], [14548926.863856547, 4480412.223228773], [14549293.280496065, 4480212.631302457], [14549647.483247736, 4480043.748782027], [14549989.472111017, 4479874.868972281], [14550355.88875037, 4479721.344221679], [14550624.594284926, 4479598.526033944], [14550856.658157144, 4479491.061295501], [14551198.647020763, 4479291.48683264], [14551565.063659478, 4479107.26761045], [14551919.266411318, 4478923.051610395], [14552285.683050882, 4478769.540739722], [14552627.671913499, 4478600.681367721], [14553006.302441431, 4478431.8247021455], [14553372.719079891, 4478278.320992562], [14553739.135719031, 4478124.8195214365], [14554117.766246844, 4477986.670110905], [14554496.39677429, 4477833.172889764], [14554875.02730134, 4477710.376725644], [14555253.657828972, 4477572.232751319], [14555620.074468583, 4477464.788689573], [14555998.704995206, 4477326.647937234], [14556377.335522415, 4477234.555108875], [14556511.688290423, 4477157.811699739], [14556878.10492996, 4476958.281453992], [14557220.093792727, 4476804.799222015], [14557598.724319693, 4476651.319227265], [14557952.92707147, 4476482.493814672], [14558331.557598298, 4476329.018516568], [14558697.974237733, 4476175.54545325], [14559076.604765655, 4476022.0746261515], [14559443.02140455, 4475883.952793535], [14559821.65193224, 4475761.179352402], [14560200.282459686, 4475638.407340859], [14560578.912986, 4475500.290538945], [14560957.54351379, 4475377.521568426], [14561348.38792862, 4475270.099892532], [14561727.01845645, 4475147.333604416], [14562117.862871993, 4475055.259828022], [14562484.27951158, 4474963.186855402], [14562899.551702326, 4474871.1146875555], [14563290.396117546, 4474809.733688274], [14563669.026644476, 4474733.007943433], [14564059.871060286, 4474656.282757424], [14564450.7154752, 4474579.558129849], [14564853.773778029, 4474518.178830546], [14565244.618193747, 4474456.79988746], [14565635.462609466, 4474395.4213030925], [14565843.09870468, 4474380.076712145], [14566026.307024052, 4474364.732145027], [14566429.365327647, 4474303.354096425], [14566624.787535438, 4474303.354096845], [14566820.209743189, 4474288.009639743], [14567040.059725929, 4474272.665205484], [14567235.481933901, 4474241.97640493], [14567430.904141523, 4474241.9764056895], [14567638.540236901, 4474226.632038639], [14567821.748556953, 4474226.632038577], [14568029.384652914, 4474211.287694249], [14568212.592971867, 4474211.287693342], [14568420.229068192, 4474195.943371697], [14568627.865163937, 4474195.943371153], [14568811.073482776, 4474195.943371623], [14569018.709579367, 4474195.94337142], [14569214.131786728, 4474195.943370581], [14569238.559562922, 4474195.943371392], [14569409.553994717, 4474195.943371547], [14569629.403978188, 4474195.943370894], [14569824.826185605, 4474211.287692922], [14570020.248392954, 4474211.287693488], [14570215.670600649, 4474211.287693272], [14570423.306696696, 4474226.632037414], [14570618.728903888, 4474226.632037795], [14570814.151111197, 4474241.97640376], [14571021.787206706, 4474272.665204512], [14571204.995527001, 4474288.009638165], [14571412.631621836, 4474288.009637828], [14571620.267718533, 4474303.354094652], [14572023.326021364, 4474364.732141971], [14572194.32045334, 4474380.076710127], [14572414.170436617, 4474395.421300662], [14572805.01485221, 4474456.799885332], [14573208.073155506, 4474518.178827347], [14573598.917569762, 4474579.558126053], [14573989.761985833, 4474656.282754001], [14574368.392513085, 4474733.007939714], [14574759.236928629, 4474809.733685015], [14575162.295230972, 4474871.114683236], [14575553.139646066, 4474963.186850651], [14575931.770173518, 4475055.259823188], [14576322.614588926, 4475147.3335995795], [14576701.245116178, 4475270.0998873925], [14577079.875643862, 4475377.521562786], [14577470.720059728, 4475500.290533416], [14577837.13669783, 4475638.407335261], [14578215.767225962, 4475761.179345143], [14578594.39775321, 4475883.95278683], [14578973.028280452, 4476022.074619254], [14579327.231032163, 4476175.54544557], [14579705.861558419, 4476329.018508264], [14580084.492086556, 4476482.493807283], [14580450.908725094, 4476651.3192181215], [14580805.111476777, 4476804.799213971], [14581183.742004093, 4476958.281445817], [14581525.730867168, 4477157.811690843], [14581879.933618782, 4477326.647927245], [14582234.136370221, 4477526.185151994], [14582588.3391218, 4477710.376715044], [14582930.327984763, 4477909.9212099165], [14583296.744624889, 4478094.119485319], [14583626.51959986, 4478309.021544471], [14583968.508463632, 4478539.278618473], [14584298.28343856, 4478754.189764326], [14584615.844526524, 4478969.105295983], [14584957.833389819, 4479199.376805741], [14585275.39447627, 4479429.653352519], [14585592.955563627, 4479659.934935787], [14585922.730539948, 4479890.221556971], [14586240.291627208, 4480135.866171305], [14586557.8527144, 4480396.869855059], [14586863.199913831, 4480642.526293688], [14587156.33322539, 4480888.188466244], [14587473.894312968, 4481164.565263089], [14587767.027623786, 4481425.59445718], [14588060.160935674, 4481701.985366741], [14588353.294247115, 4481978.383535463], [14588646.427559184, 4482254.788964908], [14588939.56086965, 4482546.558126468], [14589208.266406132, 4482822.978480075], [14589476.971941242, 4483114.763399049], [14589733.46358897, 4483421.914160338], [14590014.383012347, 4483698.35751204], [14590185.377443707, 4483898.0155623555], [14590331.944099901, 4484020.883937888], [14590661.719074445, 4484251.266009965], [14590979.280162634, 4484497.0124473], [14591296.841249231, 4484727.404948229], [14591602.18844864, 4484973.162509527], [14591919.749536166, 4485234.28621192], [14592212.882847624, 4485495.41639687], [14592518.23004711, 4485756.553064018], [14592823.577246739, 4486017.696217524], [14593116.710558899, 4486294.207799355], [14593409.843869546, 4486570.726653404], [14593690.763293965, 4486847.252777424], [14593983.89660544, 4487139.149354413], [14594252.602139814, 4487431.054034741], [14594533.52156419, 4487692.238776791], [14594802.227099039, 4487999.523250918], [14595058.718747094, 4488306.8167062495], [14595339.638170302, 4488598.753809731], [14595583.915929569, 4488906.064781649], [14595828.19368928, 4489213.384739871], [14596096.899225544, 4489536.080365369], [14596341.176984914, 4489858.785899267], [14596573.240855644, 4490181.501342655], [14596805.304727584, 4490488.858596873], [14597037.368599355, 4490826.961959009], [14597257.21858321, 4491149.707135566], [14597489.282454617, 4491472.462225323], [14597696.918549843, 4491825.967271368], [14597928.98242226, 4492148.743134879], [14598136.618516896, 4492502.270936908], [14598319.826836484, 4492840.439096991], [14598527.462932773, 4493193.990176503], [14598722.885140764, 4493547.553156926], [14598906.093460135, 4493901.128039849], [14599101.515668057, 4494254.714825081], [14599260.296211885, 4494623.687640952], [14599431.290642768, 4494977.2987543205], [14599590.07118727, 4495330.921775762], [14599773.279505912, 4495715.308132935], [14599919.846161587, 4496068.956009824], [14600078.626705699, 4496453.369387598], [14600225.193361096, 4496822.419472711], [14600347.33224078, 4497206.860440532], [14600481.685009632, 4497575.937016725], [14600616.037777228, 4497960.4055835055], [14600750.39054488, 4498344.888232693], [14600860.31553649, 4498714.004829062], [14600982.454416526, 4499098.51508797], [14601080.165519364, 4499483.0394360265], [14601202.304399468, 4499867.577874515], [14601300.01550408, 4500267.512801375], [14601385.512719708, 4500652.079992499], [14601458.796047695, 4501052.044824842], [14601471.009935707, 4501175.113995761], [14601654.218255237, 4501498.177436593], [14601800.784910476, 4501867.404980572], [14601971.779342758, 4502236.64552286], [14602118.34599798, 4502590.513240334], [14602277.126541303, 4502959.779238895], [14602423.693198, 4503344.4451490035], [14602558.045964886, 4503698.350247777], [14602680.184845533, 4504083.043249815], [14602814.537612794, 4504467.750366676], [14602936.676492875, 4504852.471599208], [14603058.815372452, 4505237.206950171], [14603180.954252187, 4505606.56617041], [14603266.451468613, 4506022.110849317], [14603388.590348229, 4506391.497726757], [14603474.087563867, 4506791.681535702], [14603547.37089101, 4507161.095535717], [14603645.08199488, 4507561.308730727], [14603718.365323769, 4507946.143524667], [14603791.648650708, 4508330.992452457], [14603864.931978848, 4508746.64517058], [14603926.001419289, 4509131.523501303], [14603987.070858913, 4509531.8119634325], [14604035.92641036, 4509916.719140585], [14604084.781962857, 4510332.43477583], [14604109.20973883, 4510717.37137208], [14604145.85140303, 4510932.942045082], [14604158.065290203, 4511117.720440137], [14604170.279178778, 4511317.900712452], [14604170.279177956, 4511533.483745455], [14604182.493066223, 4511718.272735513], [14604219.134730231, 4511933.864012004], [14604219.134729845, 4512318.859471839], [14604231.348618282, 4512519.062705031], [14604231.348618418, 4512719.269766486], [14604231.348617738, 4512904.079682778], [14604231.348618373, 4513119.695373501], [14604231.348618187, 4513319.913919597], [14604231.348618748, 4513520.136295258], [14604231.348618748, 4513720.362499544], [14604231.348618232, 4513920.592533981], [14604231.348618407, 4514120.826397525], [14604219.134730808, 4514336.467148453], [14604219.134730032, 4514721.550967597], [14604182.493066857, 4514906.396233077], [14604170.27917791, 4515122.053169531], [14604170.279178878, 4515322.310016387], [14604158.065290527, 4515507.165891377], [14604145.851402044, 4515722.835206429], [14604109.209738161, 4515923.103548852], [14604084.781962737, 4516308.245749079], [14604035.926410299, 4516708.808674816], [14603987.070858993, 4517093.9797944045], [14603926.00141845, 4517509.9805284925], [14603864.931978678, 4517895.181143359], [14603791.64865124, 4518295.804826711], [14603718.365322556, 4518681.034375972], [14603645.081995493, 4519097.098223365], [14603547.370891701, 4519466.946648656], [14603474.08756368, 4519867.630533778], [14603388.59034769, 4520237.506207204], [14603266.451468341, 4520638.219615408], [14603180.95425151, 4521008.12254481], [14603058.815372169, 4521408.865484192], [14602936.676491957, 4521778.79567707], [14602814.537612109, 4522164.153545156], [14602680.184844451, 4522534.110462632], [14602558.045964777, 4522919.496172441], [14602423.693197738, 4523289.47982265], [14602277.126541242, 4523674.893382433], [14602118.345997736, 4524044.903771376], [14601971.779341936, 4524414.927259076], [14601800.784909926, 4524784.963848068], [14601654.218254454, 4525155.013540586], [14601471.009935107, 4525525.076335961], [14601300.015503855, 4525864.3120796885], [14601129.021072082, 4526234.399998049], [14600933.598864602, 4526573.658772662], [14600750.390544403, 4526928.350179163], [14600542.754449246, 4527267.63148988], [14600347.332241392, 4527637.769124864], [14600151.910033902, 4527961.6503126025], [14599944.273938052, 4528316.388852858], [14599712.210066758, 4528640.29108562], [14599504.573970841, 4528964.203363799], [14599272.510098685, 4529318.975958536], [14599052.660115397, 4529627.483662931], [14598808.382356219, 4529951.426563321], [14598588.532372601, 4530275.379513526], [14598344.254612468, 4530599.342514882], [14598087.762964793, 4530907.88805286], [14597831.271317031, 4531216.442710067], [14597586.993558556, 4531525.006485532], [14597318.288022641, 4531833.57938278], [14597061.796375385, 4532126.732084111], [14596793.090839129, 4532419.893019322], [14596512.171415407, 4532713.062188578], [14596231.251992663, 4533021.670209015], [14595950.332568703, 4533299.42523091], [14595669.413144821, 4533577.187645228], [14595644.985369092, 4533623.48209964], [14595498.41871387, 4533777.7984313145], [14595376.279834235, 4533916.685081554], [14595119.788186248, 4534225.328697573], [14594838.868762594, 4534518.548590586], [14594582.377115823, 4534811.776722892], [14594289.243803782, 4535089.579398674], [14594008.324380705, 4535367.389470016], [14593727.404956257, 4535660.64146122], [14593446.48553373, 4535923.031808229], [14593153.352221377, 4536200.864075813], [14592848.005022287, 4536478.703743617], [14592567.08559909, 4536741.114669866], [14592249.524511898, 4537003.532198354], [14591944.177311765, 4537250.519432793], [14591614.402336147, 4537497.512515288], [14591309.055137279, 4537729.073843565], [14591003.707938092, 4537991.5162313925], [14590686.146850547, 4538238.5268653], [14590368.585762527, 4538454.665970274], [14590014.383011634, 4538686.248553525], [14589684.608036457, 4538917.836279545], [14589354.833060294, 4539118.549804093], [14589025.058085084, 4539319.267192334], [14588683.069222417, 4539535.42869948], [14588328.866470784, 4539736.154114145], [14587986.877606917, 4539952.324265979], [14587657.102632334, 4540137.616535028], [14587290.685992181, 4540338.353544246], [14586936.483240709, 4540508.210955269], [14586570.066601887, 4540678.071133686], [14586228.077738127, 4540863.376303522], [14585861.661099326, 4541033.242270953], [14585495.244460465, 4541187.668278762], [14585128.827820107, 4541342.096575501], [14584750.197293801, 4541496.527160034], [14584395.994541308, 4541650.960032226], [14584347.138989441, 4541666.403445655], [14584286.06955062, 4541697.29033999], [14583919.652910553, 4541851.726188135], [14583565.450158978, 4542021.60826263], [14583186.819632547, 4542176.048916555], [14582844.83076899, 4542330.491858846], [14582466.20024182, 4542484.937089604], [14582087.569714688, 4542608.49492355], [14581708.939186862, 4542762.94427407], [14581330.308658957, 4542901.950648047], [14580963.892020464, 4543010.067999098], [14580585.261492236, 4543149.07766965], [14580206.630964926, 4543257.1975837825], [14579803.57266225, 4543365.318620336], [14579424.942134961, 4543457.994687216], [14579192.878263632, 4543519.779190642], [14579034.09771989, 4543550.671579929], [14578655.467192937, 4543658.795661787], [14578264.622776985, 4543751.474338827], [14577873.77836218, 4543828.707200758], [14577495.147835061, 4543905.940633789], [14577092.089531014, 4543983.174640503], [14576701.245115522, 4544029.515319117], [14576310.400701316, 4544106.750241843], [14575919.556285223, 4544137.6443707235], [14575699.706302246, 4544183.98573635], [14575504.284093758, 4544199.432905103], [14575113.439679246, 4544230.327308818], [14574722.595264157, 4544276.669086714], [14574514.959168296, 4544292.1163917575], [14574331.750849022, 4544307.563720322], [14574124.11475319, 4544307.563719708], [14573916.478657782, 4544338.458445055], [14573733.270338044, 4544353.905841484], [14573525.634242143, 4544353.905841748], [14573330.212034652, 4544369.353261512], [14573134.789826233, 4544369.353261464], [14572939.367619064, 4544369.353262303], [14572719.517635329, 4544369.353261675], [14572524.095428342, 4544369.353261389], [14572328.673220538, 4544384.800704624], [14572121.037124906, 4544384.800704819], [14571937.828804424, 4544369.353261591], [14571730.192709187, 4544369.35326217], [14571522.556614075, 4544369.353262826], [14571339.34829391, 4544369.353262385], [14571131.712198837, 4544353.905842261], [14570948.503878202, 4544353.905843028], [14570740.867782762, 4544338.45844611], [14570545.44557518, 4544338.458445838], [14570350.023367973, 4544307.563721146], [14570130.173384072, 4544292.116393631], [14569934.751176836, 4544276.669089307], [14569543.906761209, 4544230.327311454], [14569153.062345682, 4544199.432906603], [14568945.42625058, 4544183.985738961], [14568762.21793041, 4544137.6443743445], [14568359.15962792, 4544106.750244441], [14567968.315212548, 4544029.515323136], [14567577.470797084, 4543983.174644471], [14567186.6263823, 4543905.940638149], [14566783.568079067, 4543828.707204437], [14566392.72366387, 4543751.474343973], [14566014.093135444, 4543658.795666206], [14565623.24872121, 4543550.671584686], [14565244.618193153, 4543457.994693514], [14564853.773778267, 4543365.3186267475], [14564475.14325144, 4543257.19758965], [14564096.512723364, 4543149.07767554], [14563693.454420516, 4543010.068005312], [14563314.823893422, 4542901.950654995], [14562936.193365432, 4542762.944282519], [14562606.418390313, 4542716.609237178], [14562398.782294482, 4542685.71932119], [14562191.146199709, 4542670.274397315], [14561812.51567194, 4542608.494931122], [14561421.671257151, 4542562.160572617], [14561030.826841386, 4542500.381747122], [14560639.982426064, 4542423.158731613], [14560236.924122458, 4542345.936288569], [14559833.86581993, 4542268.714416368], [14559467.449180512, 4542176.048925813], [14559076.604765026, 4542083.384260142], [14558697.97423732, 4541990.720416876], [14558307.129823012, 4541867.169908754], [14557928.499294864, 4541774.507987912], [14557525.440991675, 4541650.960043525], [14557146.81046419, 4541542.856792612], [14556768.179936875, 4541403.868545181], [14556389.549410133, 4541264.882152807], [14556010.918883575, 4541141.3402487645], [14555644.502244113, 4541017.7998076], [14555290.299492834, 4540863.376315873], [14554911.668964645, 4540708.95511347], [14554557.466213938, 4540523.652687981], [14554178.835686168, 4540384.678031769], [14553824.632935008, 4540214.822632615], [14553458.216296038, 4540044.970003144], [14553116.227432424, 4539844.238643887], [14552749.810793048, 4539689.83253557], [14552407.821930347, 4539504.548224409], [14552249.041385714, 4539427.347399758], [14551882.624746233, 4539303.827270621], [14551503.99422004, 4539195.748356934], [14551381.855340248, 4539149.4291654825], [14551125.36369234, 4539072.230970899], [14550746.73316546, 4538948.715047952], [14550368.102638047, 4538825.200587222], [14549989.472110914, 4538686.24856897], [14549623.055470902, 4538531.8596091075], [14549244.42494422, 4538377.472935194], [14548902.436080279, 4538238.526881961], [14548523.805553462, 4538084.144551083], [14548157.38891405, 4537914.3266261555], [14547803.186162714, 4537729.073861541], [14547436.769523405, 4537559.261718393], [14547094.7806594, 4537404.8894423675], [14546728.364020523, 4537204.208898579], [14546361.947381083, 4537018.9687477425], [14546032.172405548, 4536818.295628578], [14545677.969654717, 4536633.062331327], [14545323.76690356, 4536432.396637806], [14544993.991928555, 4536216.299437661], [14544652.003064753, 4536000.206715432], [14544322.228089612, 4535799.553195227], [14544004.667001592, 4535568.034697714], [14543662.678138765, 4535351.9554023], [14543345.117051568, 4535120.44683895], [14543027.555963451, 4534858.076675749], [14542697.780988807, 4534626.579069244], [14542380.219901314, 4534395.086599067], [14542074.87270228, 4534132.734675581], [14541781.73939042, 4533870.389347416], [14541464.178302774, 4533623.482121268], [14541158.831103068, 4533345.718475012], [14540877.911680002, 4533098.82366223], [14540572.564480469, 4532821.073979021], [14540291.645057205, 4532543.331687368], [14540010.725633612, 4532265.5967861395], [14539729.806210512, 4531972.440185714], [14539448.886786574, 4531679.291817728], [14539192.395139629, 4531417.008149412], [14538899.261827474, 4531108.447566406], [14538630.55629233, 4530815.323457989], [14538374.064645067, 4530506.780656056], [14538129.786885653, 4530198.2469714265], [14537873.295237642, 4529874.296414274], [14537616.803589916, 4529565.781417542], [14537396.953606762, 4529257.275535442], [14537152.67584749, 4528933.354167178], [14536932.825863078, 4528624.866966413], [14536676.334216729, 4528270.117950614], [14536444.27034455, 4527946.227197342], [14536248.848136436, 4527606.923846032], [14536028.998153169, 4527267.631515431], [14535821.362057582, 4526928.3502052585], [14535601.512074055, 4526589.079913595], [14535418.30375431, 4526249.820638423], [14535210.667658564, 4525910.572378158], [14535015.245451855, 4525555.915520818], [14534832.037131598, 4525185.851631445], [14534673.256587537, 4524831.219369989], [14534490.048268745, 4524476.599140016], [14534331.267724525, 4524106.573469703], [14534160.273292877, 4523751.977827433], [14533989.278861778, 4523366.561424789], [14533842.71220565, 4523027.406744612], [14533696.145549532, 4522642.01705591], [14533561.79278178, 4522287.47108967], [14533403.01223859, 4521902.108676457], [14533268.659471177, 4521532.174121515], [14533134.306703577, 4521146.839544288], [14533036.59559936, 4520776.931708183], [14532914.45671886, 4520376.21298474], [14532804.531727992, 4520006.332405602], [14532682.392847996, 4519621.054045723], [14532596.895632427, 4519220.379606621], [14532499.184527747, 4518835.130195337], [14532413.687312467, 4518449.894972147], [14532303.762320925, 4518049.265387172], [14532230.478993248, 4517664.059100537], [14532157.19566512, 4517278.866996087], [14532108.340113258, 4516878.282249036], [14532035.056785649, 4516477.712836037], [14531973.987345573, 4516092.564399366], [14531973.987345243, 4516030.941964629], [14531937.345681304, 4515784.455855288], [14531888.490129516, 4515383.928306678], [14531827.420689756, 4514983.416085525], [14531778.56513731, 4514582.919189623], [14531741.923473246, 4513982.2025730265], [14531705.281810218, 4513797.373626424]]], [[[14339432.408530401, 4075075.6362608722], [14339458.685080042, 4075084.437250298], [14339751.818391822, 4076223.538724107], [14338652.568473613, 4084644.61678336], [14338506.00181847, 4086228.895738871], [14336759.41583827, 4097428.8401910467], [14341315.196051706, 4104501.207588504], [14341938.104338527, 4105925.114876204], [14342707.57928172, 4107200.8516809675], [14343501.482000086, 4108610.2642060244], [14352735.181309098, 4124392.929620267], [14356472.63102952, 4130694.3846049826], [14357889.442034634, 4133221.908237402], [14359770.380782299, 4136374.679519459], [14361968.880617706, 4140004.4420440258], [14364619.294308105, 4144900.5395199214], [14371984.268757481, 4157187.447747604], [14376991.962826528, 4165401.170055259], [14381535.529153243, 4172024.2679253733], [14386396.65656731, 4178188.490732939], [14390109.678512042, 4182503.992319043], [14410071.323094087, 4181759.7407464916], [14411740.474114683, 4181697.507984717], [14411887.04077094, 4182668.28335924], [14412290.099073624, 4183937.8871818185], [14413145.071231768, 4185521.361705531], [14414659.593340822, 4189466.094009724], [14416235.184889486, 4190347.874119261], [14418116.123638438, 4191708.045624967], [14420009.276274431, 4193442.1331010573], [14421670.365039108, 4195430.688718817], [14423087.176044246, 4197314.91349072], [14424369.634281721, 4199438.789978044], [14425468.884199308, 4201682.765014417], [14426323.856356785, 4203642.876257398], [14426897.90909205, 4205708.102752736], [14427508.603490569, 4207998.262274081], [14427826.164578045, 4210229.005413773], [14428705.564512718, 4211007.626033115], [14430427.722717127, 4212669.865587721], [14432333.089241156, 4214736.783326548], [14433591.119701806, 4217118.712887043], [14434763.65294765, 4219576.085746894], [14435569.76955409, 4222498.676121303], [14436070.538960757, 4223802.851526747], [14436840.01390352, 4227416.375070025], [14437157.574990707, 4228856.1177854985], [14437963.691597, 4230581.057929868], [14439343.860938719, 4234526.939401433], [14440296.544200161, 4238774.472618673], [14440443.110856375, 4242242.7597973915], [14440113.33588104, 4245862.359133215], [14439478.213706143, 4248867.084183436], [14439331.647050746, 4249813.743335828], [14439649.208137836, 4250910.768227175], [14439940.719361056, 4252568.169217453], [14544627.066163512, 4252568.169285575], [14534796.669683114, 4240464.677659911], [14514759.161462417, 4205175.173351611], [14501957.419936124, 4179737.9296527705], [14485448.739516629, 4179288.840961339], [14439555.743282635, 4173635.0297790095], [14435722.322889304, 4173166.719291172], [14421250.789188549, 4166599.3964159037], [14402137.232618976, 4158446.631542469], [14394344.868232908, 4150978.506075684], [14389524.734276652, 4146676.4225404835], [14384326.1140242, 4142606.5776094557], [14365958.39800865, 4119341.9682434443], [14361694.86160705, 4101989.3419181844], [14361694.861568779, 4100867.6858379836], [14360581.666636346, 4094329.1704151263], [14359101.117467742, 4090737.3044364187], [14347779.925183792, 4070274.6906909533], [14347742.667809354, 4070231.176358625], [14339432.408530401, 4075075.6362608722]]]]}}]} \ No newline at end of file diff --git a/prediction/data/zones/특정어업수역Ⅱ.json b/prediction/data/zones/특정어업수역Ⅱ.json new file mode 100644 index 0000000..5f3cea7 --- /dev/null +++ b/prediction/data/zones/특정어업수역Ⅱ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed2", "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\u2161", "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": [[[[14026312.49388151, 3787395.72363925], [14026272.928939708, 3912341.809856742], [14026343.45295978, 3912257.596201178], [14026661.014047539, 3911892.988733094], [14026978.575133963, 3911557.559921697], [14027051.858461797, 3911470.0583396605], [14027137.355677672, 3911178.3911333913], [14027198.425118214, 3910930.4797375146], [14027271.708445255, 3910711.7387614], [14027344.991773328, 3910478.419569922], [14027406.061213229, 3910245.105041409], [14027503.77231727, 3910026.376906358], [14027577.055645473, 3909793.071410766], [14027650.338973064, 3909574.351742198], [14027723.622300655, 3909341.0552779627], [14027821.333404718, 3909122.34407479], [14027906.830620117, 3908903.6369685275], [14027992.327835856, 3908684.9339581975], [14028090.038940514, 3908451.65526086], [14028175.536155386, 3908218.3812227794], [14028273.247259233, 3908014.2702603256], [14028370.958363216, 3907781.0049572694], [14028468.669467552, 3907576.9016377437], [14028566.380571473, 3907343.6450677463], [14028664.091674816, 3907139.5493900776], [14028761.8027788, 3906920.8794053984], [14028883.941658128, 3906716.7911130013], [14028993.866650797, 3906483.5517138042], [14029091.577753998, 3906279.4710601526], [14029201.502746, 3906060.81717149], [14029323.641625034, 3905856.7438994534], [14029433.566617623, 3905638.097921009], [14029543.491609104, 3905419.4560323786], [14029665.630488753, 3905215.393960793], [14029714.486040367, 3905011.3354516793], [14029763.341592517, 3904807.2805052707], [14029824.411032889, 3904574.0792145403], [14029861.052696653, 3904340.8825751734], [14029922.122136267, 3904107.690588767], [14029983.19157568, 3903874.5032539973], [14030044.261016268, 3903641.3205705], [14030105.33045631, 3903393.5690651014], [14030178.613783477, 3903174.96915455], [14030239.683223227, 3902941.800421957], [14030312.96655101, 3902723.2089581615], [14030386.24987901, 3902475.4769059164], [14030447.319319358, 3902256.8941590027], [14030520.602646638, 3902023.7437318605], [14030606.099863403, 3901790.597953613], [14030691.597078484, 3901572.028007055], [14030764.880406654, 3901338.8912325953], [14030862.591510149, 3901120.329726406], [14030935.874838341, 3900887.201954522], [14031021.372054312, 3900668.648888896], [14031119.083157621, 3900450.0999057423], [14031204.580374023, 3900216.9854906956], [14031290.077589095, 3899998.444944336], [14031400.002581708, 3899779.908480802], [14031485.499797242, 3899561.37609934], [14031595.424789447, 3899342.847798803], [14031705.349781059, 3899138.891733757], [14031790.846996775, 3898905.803441251], [14031912.985876147, 3898701.8549923794], [14032010.696979966, 3898468.7754048174], [14032120.621971566, 3898264.8345725327], [14032230.54696337, 3898046.330481661], [14032352.685843932, 3897827.830470188], [14032450.396947037, 3897623.9008064135], [14032572.535826314, 3897405.4086795547], [14032694.674706502, 3897216.052135809], [14032816.81358599, 3896997.5676209624], [14032938.952466376, 3896793.652420099], [14033061.091346277, 3896589.740771255], [14033195.444113696, 3896385.8326731725], [14033329.796881828, 3896181.9281274145], [14033451.935761089, 3895978.0271314387], [14033586.288528644, 3895774.129686274], [14033720.641296018, 3895584.7995237107], [14033989.34683201, 3895191.5851198323], [14034258.05236752, 3894798.383918607], [14034563.399566252, 3894405.195917184], [14034844.31899014, 3894026.5829095095], [14035149.666189065, 3893647.982137594], [14035442.799501298, 3893283.954474], [14035760.360587686, 3892919.938120377], [14036077.92167527, 3892555.9330759617], [14036407.696651513, 3892191.9393388475], [14036737.471625743, 3891857.075086648], [14036870.141786523, 3891726.198837797], [14037091.674377358, 3891507.661720512], [14037421.449352924, 3891172.81701958], [14037787.86599152, 3890867.0976043935], [14038142.06874289, 3890532.271202124], [14038178.710406706, 3890503.1563148433], [14038215.352071756, 3890459.48412038], [14038569.554822957, 3890139.2263254467], [14038923.75757426, 3889818.9772791206], [14039302.388101518, 3889513.29316919], [14039681.018628426, 3889207.617028156], [14040059.649154926, 3888931.0597660383], [14040426.065794326, 3888639.953906682], [14040816.91020996, 3888348.855272826], [14041232.18240151, 3888072.318264321], [14041623.02681568, 3887810.3418493513], [14041818.449023297, 3887679.3558354746], [14042026.085119475, 3887548.3712851256], [14042221.507326983, 3887431.941802017], [14042429.14342227, 3887300.9600144974], [14042624.565629626, 3887184.532986891], [14042844.415613849, 3887053.553961726], [14043052.051709011, 3886951.682398294], [14043259.687804861, 3886820.705972922], [14043467.323900312, 3886704.283712141], [14043687.173883341, 3886602.415180504], [14043894.809980085, 3886471.4426559876], [14044102.446075153, 3886369.576147044], [14044310.082171045, 3886267.7105223597], [14044542.146041911, 3886151.2937484276], [14044749.78213759, 3886049.4300192064], [14044957.418233024, 3885947.567174553], [14045189.4821048, 3885860.256868118], [14045397.118200412, 3885743.844137209], [14045629.182072148, 3885656.5353473793], [14045849.032055777, 3885569.2272066567], [14046056.668151285, 3885481.9197152676], [14046288.73202292, 3885380.06179798], [14046508.58200612, 3885292.755714113], [14046740.6458772, 3885205.4502798985], [14046960.495860584, 3885132.696247827], [14047192.559732666, 3885045.392004632], [14047412.409715734, 3884958.0884115743], [14047644.473587925, 3884885.335912664], [14047864.323571343, 3884812.583865854], [14048096.387442762, 3884739.8322684863], [14048316.237426357, 3884667.081122923], [14048560.515186315, 3884579.7803441263], [14048780.365169752, 3884521.5801856825], [14049012.429040425, 3884448.8303926187], [14049232.279024879, 3884390.630883527], [14049476.556783637, 3884332.431662887], [14049708.620655935, 3884288.7824363117], [14049940.684527121, 3884216.034086747], [14050172.74839862, 3884157.835731066], [14050417.026158523, 3884099.6376657546], [14050636.876141772, 3884055.989305159], [14050881.153900763, 3884012.3411064013], [14051101.003885025, 3883968.693071144], [14051345.28164453, 3883925.045197191], [14051589.55940309, 3883881.397485363], [14051809.409386702, 3883837.7499363376], [14052053.687146327, 3883794.102549823], [14052297.96490621, 3883765.00438295], [14052530.028777948, 3883721.357266986], [14052774.306537522, 3883692.2592790583], [14052994.1565203, 3883677.710312659], [14053238.434280407, 3883648.6124334624], [14053482.712039571, 3883619.514626246], [14053726.989798827, 3883604.9657499343], [14053946.839782678, 3883575.8680508113], [14054203.331430309, 3883561.3192288275], [14054435.395301264, 3883546.770424295], [14054667.459173407, 3883532.221638027], [14054911.736932652, 3883517.6728706607], [14055156.014692299, 3883503.1241203477], [14055388.078563493, 3883503.1241198485], [14055632.356323073, 3883503.1241198387], [14055876.634082645, 3883503.1241195546], [14056120.911842786, 3883488.575388423], [14056352.97571373, 3883488.575387674], [14056585.039585229, 3883503.1241202564], [14056829.31734454, 3883503.1241205744], [14057061.381216811, 3883503.1241201926], [14057305.658975704, 3883517.672870415], [14057549.936734984, 3883532.2216384728], [14057782.000606766, 3883546.7704246663], [14058026.27836623, 3883561.3192288917], [14058270.556125652, 3883575.868050909], [14058514.833885796, 3883604.9657495967], [14058734.683868349, 3883619.5146270352], [14058978.96162855, 3883648.612433189], [14059211.02550005, 3883663.161363651], [14059455.303259443, 3883692.2592792334], [14059699.581018375, 3883721.35726594], [14059919.431002565, 3883765.0043828213], [14060163.708761357, 3883794.1025502756], [14060407.98652079, 3883837.7499373327], [14060652.26428107, 3883881.397485507], [14060872.114264324, 3883925.0451974412], [14061116.392023854, 3883968.6930701793], [14061348.455895012, 3884012.3411065093], [14061580.519767078, 3884055.9893042506], [14061824.797526775, 3884099.6376657034], [14062044.647510495, 3884157.8357315855], [14062288.925269853, 3884216.0340869497], [14062508.775252802, 3884288.782436949], [14062753.053012297, 3884332.4316628594], [14062985.116884248, 3884390.6308830315], [14063204.966867786, 3884448.8303930266], [14063449.244626828, 3884521.5801856043], [14063669.09461016, 3884579.780344494], [14063901.15848242, 3884667.0811240533], [14064121.008465912, 3884739.832268733], [14064353.072337683, 3884812.5838650106], [14064585.136208225, 3884885.3359133895], [14064817.200079866, 3884958.088411405], [14065037.050063629, 3885045.3920052126], [14065269.113934992, 3885132.6962482887], [14065488.963918757, 3885205.450280084], [14065708.813902, 3885292.755713535], [14065928.663886081, 3885380.0617970554], [14066160.72775689, 3885481.9197158483], [14066380.577740904, 3885569.2272062856], [14066588.21383698, 3885656.5353471697], [14066820.277708739, 3885743.8441374716], [14067040.127691144, 3885860.2568676393], [14067259.977674901, 3885947.5671746274], [14067467.613770252, 3886049.4300197763], [14067687.463753887, 3886151.2937480435], [14067907.313738173, 3886267.7105219206], [14068114.949834043, 3886369.5761465007], [14068322.585929519, 3886471.442655181], [14068554.649800802, 3886602.4151809313], [14068750.072007738, 3886704.283711534], [14068957.708103167, 3886820.705972922], [14069165.344199639, 3886951.6823977036], [14069372.980294656, 3887053.5539613245], [14069592.83027846, 3887184.53298712], [14069788.25248586, 3887300.9600143926], [14069995.888581414, 3887431.9418018376], [14070203.524677217, 3887548.3712856653], [14070398.946884345, 3887679.3558351737], [14070606.58297968, 3887810.341848666], [14070997.427395098, 3888072.318263754], [14071400.485698925, 3888348.855272708], [14071791.330113634, 3888639.95390655], [14072157.746753618, 3888931.059766593], [14072548.59116818, 3889207.617027234], [14072915.007807814, 3889513.2931684074], [14073293.638334833, 3889818.9772791504], [14073647.841086097, 3890139.226324991], [14074014.257724732, 3890459.4841204123], [14074356.24658839, 3890779.750664216], [14074710.449339252, 3891114.584135423], [14075052.438203165, 3891463.985782468], [14075369.999290047, 3891813.397846801], [14075699.774265824, 3892162.8203282068], [14076029.549241148, 3892526.813160914], [14076334.896440182, 3892890.817300508], [14076652.457527356, 3893254.8327489593], [14076933.376951266, 3893633.4208147195], [14077238.72415068, 3894012.0211142646], [14077519.643573463, 3894405.1959178024], [14077812.776884863, 3894798.383917827], [14078081.482420994, 3895191.5851198635], [14078337.974068029, 3895584.7995231976], [14078472.326835675, 3895788.693671682], [14078594.465715563, 3895992.591370282], [14078728.818483, 3896196.4926201487], [14078850.957363801, 3896400.3974202448], [14078960.88235518, 3896604.30577156], [14079083.021235045, 3896808.2176739727], [14079205.16011431, 3897012.1331286556], [14079327.298994398, 3897230.6179148587], [14079534.935089272, 3897623.9008055353], [14079766.998961411, 3897594.7682869313], [14079999.062833289, 3897551.069644458], [14080243.34059258, 3897521.9373066192], [14080475.404464027, 3897492.8050412447], [14080707.468334954, 3897449.106778766], [14080951.746094488, 3897419.9746949435], [14081196.023853954, 3897405.408680112], [14081415.87383798, 3897376.2767037847], [14081672.365485134, 3897361.710744355], [14081904.429357013, 3897332.5788773843], [14082136.49322842, 3897318.012970849], [14082380.770987837, 3897303.44708331], [14082625.048747523, 3897303.447082881], [14082844.89873114, 3897288.8812134634], [14083101.39037848, 3897288.8812134196], [14083333.454250371, 3897274.3153610313], [14083577.73200986, 3897274.315360886], [14083809.795881303, 3897274.3153613866], [14084054.07364039, 3897274.315361214], [14084298.35139951, 3897274.3153610793], [14084530.415272055, 3897288.881213491], [14084774.693031047, 3897288.881213693], [14085018.970789962, 3897303.447082888], [14085263.248550324, 3897303.4470835133], [14085483.09853329, 3897318.012971403], [14085727.376292782, 3897332.5788774174], [14085971.654052334, 3897361.71074422], [14086203.7179239, 3897376.276704187], [14086447.995682908, 3897405.4086796427], [14086692.27344248, 3897419.9746941905], [14086912.123426246, 3897449.1067788443], [14087156.401186522, 3897492.8050409877], [14087400.678945886, 3897521.9373064945], [14087620.528929327, 3897551.0696441643], [14087864.806688221, 3897594.7682873094], [14088109.084447999, 3897623.900805951], [14088341.148319451, 3897667.5997203756], [14088573.212191245, 3897696.7324211453], [14088805.276063038, 3897740.4316074788], [14089037.339933824, 3897784.1309570693], [14089281.617693743, 3897827.830470751], [14089501.4676769, 3897900.66335366], [14089745.745436855, 3897944.3633015817], [14089965.595419774, 3898002.630152866], [14090209.873179223, 3898060.897294051], [14090441.937050942, 3898119.164725707], [14090674.000922583, 3898162.8654892095], [14090906.064793834, 3898235.7004582426], [14091125.914777994, 3898308.535880703], [14091370.19253783, 3898381.3717555343], [14091590.04252131, 3898439.6407829127], [14091822.106392259, 3898512.4774739025], [14092041.956375973, 3898585.314618505], [14092274.020247314, 3898672.7197900466], [14092493.870230613, 3898745.557932266], [14092591.581335085, 3898774.693316213], [14092616.00911053, 3898760.125616015], [14092860.286870733, 3898701.8549935655], [14093080.136854356, 3898629.0171225015], [14093312.200724699, 3898570.7471534302], [14093556.478484493, 3898527.044866997], [14093776.328468569, 3898468.775404192], [14094020.606227417, 3898425.073498839], [14094264.883987851, 3898381.3717556526], [14094484.733970387, 3898337.670176473], [14094729.011730317, 3898293.9687599814], [14094961.075602157, 3898235.7004585327], [14095193.13947348, 3898206.566416765], [14095437.417232322, 3898162.865489851], [14095669.481104298, 3898133.7316290126], [14095901.544975886, 3898090.0309733814], [14096145.8227352, 3898060.897294544], [14096377.886607243, 3898031.7636878835], [14096622.164366543, 3898017.196910956], [14096866.442125635, 3897988.063412855], [14097086.292109383, 3897973.4966916144], [14097330.569869047, 3897944.3633026695], [14097574.847628593, 3897929.7966353055], [14097806.91150033, 3897915.2299849386], [14098051.189258894, 3897900.6633545416], [14098295.467019573, 3897886.0967414593], [14098515.317002017, 3897871.5301457075], [14098759.594761733, 3897871.5301461737], [14099003.872522173, 3897871.5301457415], [14099248.15028098, 3897842.3970105853], [14099480.214152526, 3897842.3970112065], [14099724.491911555, 3897842.3970108926], [14099968.769672029, 3897871.5301465685], [14100200.833543025, 3897871.530146231], [14100445.111302666, 3897871.530146376], [14100689.389062308, 3897886.0967411445], [14100909.23904563, 3897900.6633543223], [14101153.516805617, 3897915.229985139], [14101397.794564402, 3897929.796634782], [14101629.858436095, 3897944.363301649], [14101874.136195809, 3897973.4966918174], [14102118.413954498, 3897988.0634127976], [14102338.263938468, 3898017.1969116074], [14102582.541697871, 3898031.763687157], [14102826.819457443, 3898060.897294939], [14103046.669441475, 3898090.0309742764], [14103290.947201025, 3898133.731628701], [14103535.224959875, 3898162.865489382], [14103767.288831646, 3898206.566415829], [14104011.566590969, 3898235.7004580023], [14104243.63046214, 3898293.9687599214], [14104475.694334047, 3898337.6701762597], [14104707.758205285, 3898381.3717553043], [14104952.035964744, 3898425.0734980954], [14105171.885948928, 3898468.7754048845], [14105416.163708236, 3898527.044866273], [14105636.013691971, 3898570.7471529637], [14105880.291451601, 3898629.017122684], [14106100.141434586, 3898701.8549932986], [14106344.419193909, 3898760.1256155283], [14106576.483066218, 3898818.3965284377], [14106796.33304983, 3898891.2355768005], [14107040.61080865, 3898949.5071421904], [14107260.460792817, 3899022.347006401], [14107492.524664072, 3899109.7554428596], [14107712.374646941, 3899168.0280965483], [14107944.43851916, 3899240.8693216643], [14108164.288502041, 3899313.7110006236], [14108396.35237358, 3899401.1216131775], [14108616.202357315, 3899488.5328786755], [14108848.26622925, 3899575.9447972635], [14109068.116212262, 3899648.788562357], [14109300.180084735, 3899736.201678742], [14109507.81617953, 3899823.6154470327], [14109739.8800512, 3899925.5990036307], [14109959.730035394, 3900013.0141874147], [14110167.366130177, 3900100.430024312], [14110399.430001773, 3900202.4159939], [14110607.066097446, 3900318.9724758286], [14110826.91608077, 3900406.3905996806], [14111046.766064817, 3900508.3792349175], [14111254.402160756, 3900610.368760547], [14111462.038255833, 3900726.9293071674], [14111694.102127243, 3900828.9207385355], [14111901.738223149, 3900945.483462624], [14112109.374317858, 3901062.047348597], [14112317.010413814, 3901178.6123951804], [14112536.86039789, 3901295.1786045753], [14112732.282604866, 3901411.745975727], [14112939.91870098, 3901542.8856554995], [14113147.554796439, 3901659.455495108], [14113342.977004122, 3901776.026497272], [14113562.826987848, 3901907.170262538], [14113758.249194663, 3902023.7437324016], [14113965.885290999, 3902154.8902756744], [14114161.307498729, 3902300.6103812656], [14114552.151913268, 3902562.911149271], [14114942.996328058, 3902839.790564282], [14115333.840744248, 3903131.2496635215], [14115712.471271036, 3903408.142537925], [14116091.101798624, 3903714.189658899], [14116457.518437536, 3904020.244793169], [14116823.935076432, 3904326.30794037], [14117190.351715742, 3904646.9541180977], [14117544.554466687, 3904967.6090920256], [14117874.329442928, 3905288.272862804], [14118228.532193437, 3905623.52166774], [14118558.307168506, 3905973.3567619547], [14118900.296031933, 3906323.2023288426], [14119217.857119897, 3906687.6359336833], [14119535.41820684, 3907022.924889553], [14119852.979293982, 3907387.380320937], [14120146.112605767, 3907766.426031002], [14120329.320924878, 3907999.69104288], [14120659.095900508, 3908145.484040798], [14120891.159772767, 3908247.5402225223], [14121098.79586814, 3908364.1769507537], [14121306.43196353, 3908466.2350431], [14121538.49583534, 3908568.294027048], [14121746.13193052, 3908699.514031146], [14121953.768026086, 3908801.575054541], [14122161.404121136, 3908918.217315232], [14122381.254104782, 3909034.860741095], [14122588.890200352, 3909166.0859882417], [14122784.312407838, 3909282.7318893564], [14122991.948504105, 3909399.378956402], [14123187.370711256, 3909530.6082997248], [14123407.22069531, 3909661.8391170804], [14123602.64290326, 3909778.4899717756], [14123675.926230324, 3909822.2343429914], [14123871.348437805, 3909851.3973478205], [14124103.412310153, 3909909.7235756326], [14124335.476181583, 3909953.4684379986], [14124579.753940387, 3909997.2134634694], [14124799.603924207, 3910040.9586544754], [14125043.88168351, 3910099.2858296824], [14125263.731667727, 3910143.0314019676], [14125508.009426983, 3910201.3590867375], [14125752.28718599, 3910259.6870635124], [14125972.137169205, 3910332.59744405], [14126204.201041877, 3910390.92607536], [14126424.051024832, 3910449.254999375], [14126668.328784036, 3910507.584213994], [14126888.17876787, 3910565.913720107], [14127120.242639447, 3910638.8260132936], [14127340.092622804, 3910726.3213651967], [14127584.370382065, 3910799.234659938], [14127816.434254477, 3910872.148409907], [14128036.284238072, 3910945.0626162733], [14128268.348108647, 3911017.9772767853], [14128488.198092327, 3911090.892392731], [14128720.261964472, 3911192.974320413], [14128940.111948168, 3911280.4738266575], [14129147.748043166, 3911367.973988523], [14129379.811914971, 3911440.891291154], [14129599.661898108, 3911557.559922042], [14129819.511881288, 3911645.0621605986], [14130039.361864883, 3911732.5650563217], [14130271.425736733, 3911820.068606416], [14130479.061832469, 3911936.741027793], [14130686.697928369, 3912038.8303543963], [14130918.761799408, 3912140.920572288], [14131126.397895552, 3912243.0116843027], [14131334.033990381, 3912359.6883341745], [14131566.09786253, 3912461.7813591575], [14131773.73395724, 3912578.460196206], [14131981.370053304, 3912680.5551354536], [14132189.006148996, 3912811.821370374], [14132408.856131978, 3912913.918351353], [14132604.278340138, 3913030.6017091586], [14132811.914436067, 3913161.8718802584], [14133019.55053132, 3913278.557718527], [14133214.972738754, 3913409.8306803126], [14133422.608834058, 3913526.518996874], [14133618.03104185, 3913657.7947483873], [14133837.881025733, 3913789.0719763637], [14134033.303232925, 3913920.3506824095], [14134424.147647737, 3914197.4994676104], [14134814.99206299, 3914474.654837102], [14135205.836479066, 3914737.2291565286], [14135584.467006274, 3915043.5733327474], [14135950.883645028, 3915335.3371710163], [14136329.514172366, 3915641.697056373], [14136695.93081197, 3915948.0649892436], [14137050.133562554, 3916269.0305042304], [14137416.550201891, 3916590.0048536095], [14137758.539065152, 3916925.578393816], [14138112.741816988, 3917261.161591096], [14138124.955704955, 3917290.3431960098], [14138222.66680882, 3917363.297525027], [14138576.869559862, 3917669.710695894], [14138931.072311323, 3918005.315314095], [14139297.488950564, 3918326.3374690292], [14139627.263925616, 3918676.5535388705], [14139969.25278898, 3919012.187147011], [14140286.813875556, 3919362.423825106], [14140616.588851899, 3919712.671030513], [14140934.149938418, 3920077.5230633905], [14141251.711026246, 3920442.3865206414], [14141557.058225727, 3920821.8566365796], [14141850.191537393, 3921186.743403776], [14142143.324847814, 3921580.833950086], [14142412.030383276, 3921960.3411501986], [14142705.163695073, 3922354.4578704755], [14142973.869230365, 3922748.5879268395], [14143108.221998872, 3922952.9569106484], [14143230.360877866, 3923157.3294808976], [14143364.71364529, 3923347.1072222115], [14143486.852526005, 3923566.085382383], [14143621.205292745, 3923770.468714929], [14143743.344173217, 3923974.8556338386], [14143853.269164307, 3924179.246141352], [14143975.40804453, 3924398.239951181], [14144085.333036179, 3924602.6378918802], [14144207.471916584, 3924807.039420669], [14144329.61079611, 3925011.4445379367], [14144427.321899917, 3925245.054782049], [14144537.246891692, 3925449.467591055], [14144659.38577103, 3925668.485299123], [14144757.096875027, 3925872.905545292], [14144867.021866404, 3926091.931221066], [14144976.946858248, 3926310.9610188995], [14145062.444074264, 3926529.9949393696], [14145172.369066445, 3926749.032982101], [14145257.866282122, 3926968.07514812], [14145343.36349802, 3927187.1214373973], [14145453.288489206, 3927420.7753580655], [14145538.785705116, 3927639.830169832], [14145636.496809188, 3927858.8891063603], [14145709.78013733, 3928077.9521672083], [14145831.91901659, 3928194.7874860945], [14146173.907880068, 3928516.090663645], [14146528.11063154, 3928837.402716595], [14146857.885606105, 3929187.935079745], [14147199.87446994, 3929538.4780067294], [14147529.649444804, 3929874.424892384], [14147847.210531974, 3930224.988511853], [14148164.771620288, 3930590.1701873746], [14148470.118818687, 3930955.363331506], [14148763.252130508, 3931335.176369344], [14149068.599329932, 3931715.0018159356], [14149349.518753031, 3932094.839672609], [14149642.652064433, 3932489.2998140086], [14149911.357600631, 3932869.1629744656], [14150167.84924795, 3933263.649396584], [14150302.202015493, 3933482.814306461], [14150436.554783072, 3933672.760570551], [14150558.69366279, 3933891.9331968473], [14150693.04643046, 3934081.88614829], [14150815.185310263, 3934301.0664916416], [14150937.324189857, 3934505.638541194], [14151059.463069875, 3934710.2141920165], [14151169.388061073, 3934914.793446248], [14151279.313053045, 3935133.9895019485], [14151401.45193336, 3935338.576218246], [14151499.163036935, 3935557.7802698156], [14151621.301916642, 3935762.3744509714], [14151731.226908179, 3935981.586499216], [14151841.151899958, 3936186.188145459], [14151938.863003807, 3936405.408193718], [14152195.354651982, 3936726.938415809], [14152488.487962838, 3937121.555854616], [14152757.193498401, 3937516.186704223], [14153013.685146198, 3937910.830965573], [14153148.037913712, 3938130.0835739793], [14153282.39068159, 3938320.1058491296], [14153404.52956081, 3938539.3661864866], [14153526.668440903, 3938729.3951621894], [14153661.021208057, 3938934.045228578], [14153783.16008862, 3939153.3171624425], [14153893.085079862, 3939343.356187306], [14154003.010072157, 3939562.635853141], [14154125.148951644, 3939767.300611019], [14154247.287830727, 3939986.588286231], [14154344.998935373, 3940191.260520088], [14154454.923926366, 3940410.5562055036], [14154577.062806187, 3940615.235917028], [14154686.987798307, 3940834.5396143007], [14154784.698902179, 3941053.847455504], [14154894.623893438, 3941273.159443157], [14154980.121109651, 3941477.8543701675], [14155090.046101721, 3941711.795852182], [14155175.543317659, 3941916.4985195934], [14155285.468309484, 3942150.4488461153], [14155370.965524651, 3942369.7815635717], [14155468.676628448, 3942589.1184271937], [14155554.173845042, 3942808.4594375123], [14155639.671059819, 3943042.427754295], [14155725.168275682, 3943261.7773370524], [14155810.665492112, 3943481.131068717], [14155883.948820263, 3943700.4889500365], [14155957.232147578, 3943934.475261892], [14156030.51547582, 3944153.84171713], [14156116.012691723, 3944387.8371760393], [14156189.296019575, 3944621.8373581613], [14156262.579347137, 3944855.842261796], [14156335.86267503, 3945075.226148344], [14156396.932115378, 3945309.240202267], [14156458.001554107, 3945543.2589806733], [14156531.284882791, 3945777.2824817533], [14156580.140434783, 3945996.683805485], [14156641.209874306, 3946245.343659381], [14156702.279314281, 3946479.3813347146], [14156763.348753486, 3946713.4237364368], [14156836.632082347, 3947093.7527188975], [14156922.129297458, 3947210.779532316], [14157044.268176915, 3947415.5792986215], [14157166.407056939, 3947620.3826843738], [14157300.759824937, 3947825.1896898304], [14157422.89870435, 3948030.000315225], [14157545.037583863, 3948234.8145606047], [14157667.17646415, 3948454.2624128303], [14157764.887568416, 3948659.084157569], [14157887.026448287, 3948878.5400449373], [14157996.951438919, 3949083.369290671], [14158119.090319661, 3949288.202157963], [14158216.801423091, 3949507.6699629608], [14158326.726414407, 3949727.1419266723], [14158424.437519114, 3949931.986177989], [14158534.362510465, 3950166.098329143], [14158644.287502103, 3950370.950342997], [14158729.78471748, 3950590.4386638855], [14158815.281933218, 3950809.931145622], [14158925.206925157, 3951029.427785851], [14159010.704141628, 3951248.9285875857], [14159108.415245011, 3951483.0673614168], [14159193.912461305, 3951702.576762513], [14159291.6235645, 3951936.724709892], [14159364.906892387, 3952141.6080482737], [14159450.404108545, 3952375.764874187], [14159523.687435796, 3952595.291199293], [14159621.398540307, 3952829.4572015903], [14159694.681868514, 3953048.992130602], [14159767.965195222, 3953283.1673107734], [14159841.248523328, 3953502.71084457], [14159914.531851163, 3953751.5318834265], [14159975.601291291, 3953971.084300843], [14160036.670731131, 3954205.278137325], [14160097.740171447, 3954424.8391631977], [14160171.023498941, 3954673.6800276935], [14160232.092939438, 3954893.249940999], [14160293.162378157, 3955127.4624392823], [14160342.017930873, 3955361.679679122], [14160403.087371092, 3955610.540691105], [14160451.942922773, 3955830.128382198], [14160500.798474764, 3956064.3598468173], [14160549.65402654, 3956298.596053535], [14160598.509577785, 3956547.477220151], [14160622.937354516, 3956693.880406732], [14160636.617809776, 3956751.2753614364], [14160696.220681982, 3957001.3331325892], [14160757.290121438, 3957220.947224554], [14160818.359561661, 3957469.8482368095], [14160867.215113139, 3957704.112907182], [14160916.070665386, 3957938.3823223934], [14160952.712329246, 3958172.656482196], [14161001.567881363, 3958406.9353898573], [14161050.423433455, 3958641.2190423324], [14161099.278985005, 3958890.1506262408], [14161135.920648871, 3959109.800590544], [14161172.562312365, 3959358.7422623085], [14161209.203976428, 3959593.0452026036], [14161245.845640494, 3959827.352890803], [14161270.273416875, 3960061.6653290996], [14161306.915080808, 3960310.627497995], [14161319.128968159, 3960544.9497317653], [14161343.55674493, 3960779.276716447], [14161380.198409086, 3961013.6084507345], [14161392.412296837, 3961262.5911258464], [14161404.626184527, 3961511.5791632985], [14161429.053960387, 3961745.92574739], [14161441.267848562, 3961994.924199761], [14161453.481735952, 3962214.63317148], [14161453.481736058, 3962463.6417239243], [14161465.695623817, 3962698.007616471], [14161465.695623918, 3962947.0265865847], [14161490.123400616, 3963181.402284813], [14161490.123400327, 3963430.4316741815], [14161490.12340009, 3963664.817180731], [14161490.123400327, 3963913.856991753], [14161465.695623929, 3964148.2523065866], [14161453.481736246, 3964382.6523781596], [14161453.481736366, 3964631.7076659855], [14161441.267847814, 3964866.11754977], [14161429.053960953, 3965100.5321910167], [14161429.053960415, 3965349.602961888], [14161392.412296638, 3965584.027417495], [14161380.198408043, 3965833.1086162445], [14161343.556744935, 3966067.542888706], [14161343.556744233, 3966155.4569668346], [14161331.342857195, 3966316.6345174764], [14160989.35399289, 3968675.9452985157], [14160366.445706693, 3971035.738514718], [14159609.18465247, 3973249.3995169974], [14158717.57082961, 3975478.1498437845], [14157471.754256403, 3977560.6613335563], [14155041.190549271, 3980831.8443643325], [14152329.70741956, 3983619.6844641836], [14151987.71855616, 3983825.1309771356], [14148885.391009744, 3986188.029616028], [14146809.030054526, 3987362.3212974994], [14144659.385770556, 3988331.2022260944], [14142448.672047747, 3989109.3020907817], [14140018.108340502, 3989667.217435894], [14137673.041849403, 3990078.3302842528], [14136695.930811903, 3990151.744841114], [14135095.911487218, 3991458.6024279883], [14131847.017286118, 3993896.5093908194], [14128537.053643916, 3995644.4841260226], [14126998.103759484, 3996261.479934003], [14128940.111947352, 3996599.372644142], [14130882.120135639, 3997025.425458683], [14132824.128323132, 3997436.8018056713], [14133581.38937842, 3997642.4955095113], [14135169.19481456, 3997995.1218662434], [14137111.20300309, 3998215.51884181], [14138979.92786301, 3998421.2265061126], [14140848.652722916, 3998685.713208066], [14142790.66091092, 3998906.123450371], [14144732.669099433, 3999170.621330323], [14146601.39395857, 3999317.5672330167], [14148543.402146455, 3999596.769632924], [14150412.12700758, 3999875.9788301843], [14152329.70741913, 4000081.7162713646], [14154271.715607245, 4000302.153339294], [14155041.190549305, 4000360.9372721342], [14156983.19873771, 4000640.165071943], [14158912.993037248, 4000919.399671307], [14160781.717897676, 4001125.1558314012], [14162797.009413889, 4001345.612956999], [14164580.237058092, 4001610.1671023793], [14166522.245245533, 4001815.935658166], [14167120.725757152, 4001933.519348061], [14167987.911802663, 4002036.4060658], [14173362.02251159, 4002697.8427284476], [14178760.46447004, 4003210.7735754605], [14183572.832858339, 4003668.0188718894], [14206901.358889744, 4005932.0826477716], [14210370.103074845, 4006505.5204501776], [14216550.330390768, 4008623.077985625], [14220983.971725512, 4010961.6694398993], [14225038.982532345, 4014271.8137577237], [14230608.515449705, 4021896.1459267535], [14237533.789931284, 4030556.9660328445], [14248477.43355596, 4044607.966866741], [14248819.422419427, 4044725.9920548676], [14248894.821504684, 4044751.2460003477], [14332384.43799789, 4072715.0099619655], [14339458.685080042, 4075084.437250298], [14339432.408530401, 4075075.6362608722], [14347742.667809354, 4070231.176358625], [14347779.925183792, 4070274.6906909533], [14337204.573632397, 4057923.327535437], [14332384.437499993, 4053484.2600356997], [14325516.027051724, 4047158.849973145], [14297318.800024424, 4007338.115534628], [14296205.605126167, 4000638.8920895318], [14289147.949369663, 3995496.544127517], [14248894.821533248, 3948046.126943193], [14240260.255669821, 3937867.6942487117], [14234601.398877025, 3931197.0296311476], [14233310.092817476, 3925646.324512699], [14227187.520809716, 3914566.141529868], [14215309.731123524, 3889770.2929695556], [14213829.181949753, 3888669.1786231115], [14154273.254398886, 3837917.649432496], [14085910.608311396, 3787395.72363925], [14026312.49388151, 3787395.72363925]]]]}}]} \ No newline at end of file diff --git a/prediction/data/zones/특정어업수역Ⅲ.json b/prediction/data/zones/특정어업수역Ⅲ.json new file mode 100644 index 0000000..186078b --- /dev/null +++ b/prediction/data/zones/특정어업수역Ⅲ.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed3", "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\u2162", "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": [[[[13817590.293393573, 4163976.6556012244], [13935718.55954324, 4163976.6556012244], [13935619.01320107, 4163881.1438622964], [13923844.505073382, 4152583.8553656973], [13918348.255484505, 4147059.076131751], [13915673.414018149, 4144230.73373971], [13914293.244677586, 4142310.8465966308], [13912412.305929314, 4139320.052336007], [13910836.71437977, 4135854.113539339], [13910055.02554902, 4132998.86846704], [13908345.081232697, 4127825.5937968497], [13907722.172945773, 4125120.982583305], [13907392.39797139, 4121971.379578588], [13905548.100886794, 4109218.5915996092], [13903996.937114129, 4098184.7930658716], [13902433.559452614, 4086362.162986858], [13899270.162467, 4063581.503750727], [13896473.182120506, 4043914.5936157014], [13897865.565350175, 4034226.54128085], [13899148.023587234, 4029334.0367157785], [13901432.020639004, 4024841.2655827063], [13904314.498200562, 4020453.314503754], [13908809.208975887, 4016346.6591829173], [13913084.069767442, 4012962.3532931497], [13916381.819520816, 4010770.4459119253], [13921743.716342429, 4008961.335245877], [13958947.219114931, 4002242.1822701376], [13979370.589551304, 3998601.864321506], [14011454.723518057, 3992883.0996783567], [14012407.40678011, 3992706.863639312], [14013372.303930415, 3992530.6303036474], [14014324.987193013, 3992369.0854542954], [14015277.670454111, 3992192.8573017977], [14016230.35371629, 3992031.3172021704], [14017195.250866242, 3991855.094230278], [14018147.934129067, 3991678.873962591], [14019100.617390765, 3991502.656396452], [14020053.300652908, 3991341.126002646], [14021018.197802687, 3991179.5978787914], [14021909.811624419, 3991018.0720274393], [14022813.639334908, 3990856.5484449966], [14023766.32259687, 3990650.976261227], [14024731.219746705, 3990445.407755316], [14025525.122466035, 3990298.575359531], [14026477.805727897, 3990063.6474289736], [14027442.702878024, 3989828.724300673], [14027845.761180786, 3989740.629365874], [14028798.444442954, 3989505.7128408654], [14029763.341592584, 3989270.80111587], [14030716.024854451, 3989050.57573502], [14031668.70811654, 3988815.6733126394], [14033073.30523366, 3988478.0094901477], [14033024.449681701, 3988213.757764475], [14033000.021905882, 3987949.512112862], [14032963.380242558, 3987582.5143390666], [14032938.952466676, 3987332.9625437283], [14032914.524689825, 3987098.09521391], [14032902.310802005, 3986863.232680407], [14032877.883026825, 3986613.69649413], [14032877.883025693, 3986378.843854627], [14032841.241362942, 3986143.996010515], [14032841.241361871, 3985894.47543004], [14032829.027473792, 3985659.6374763353], [14032829.027474709, 3985410.127403529], [14032829.027473787, 3985175.2993370066], [14032829.02747455, 3984940.4760656278], [14032816.813586919, 3984690.9815914035], [14032829.027474267, 3984456.1682059844], [14032829.027474323, 3984221.3596118884], [14032829.02747492, 3983971.8807323813], [14032841.24136266, 3983737.082022118], [14032841.24136211, 3983487.613641918], [14032865.66913836, 3983252.8248136323], [14032877.88302635, 3983003.3669318845], [14032902.310802022, 3982783.2615266712], [14032914.52469013, 3982533.813822452], [14032938.952465724, 3982299.044452602], [14032963.38024159, 3982064.2798706265], [14032975.59412962, 3981814.847749137], [14033012.23579437, 3981594.76507107], [14033036.663569707, 3981345.3431236055], [14033073.305233562, 3981110.597991455], [14033097.733010307, 3980861.18653368], [14033146.588561533, 3980641.122086566], [14033171.016337542, 3980391.7207993036], [14033219.871889306, 3980171.665326035], [14033268.727441864, 3979922.2742063804], [14033305.369105555, 3979687.5580867655], [14033354.224656736, 3979452.846750307], [14033403.080208756, 3979218.140198897], [14033464.149649367, 3978968.769728522], [14033500.79131351, 3978748.741443538], [14033561.860753022, 3978514.0492399754], [14033622.930192148, 3978279.3618192296], [14033683.999632282, 3978044.6791785043], [14033745.06907279, 3977824.6685465015], [14033806.138512583, 3977575.3282415215], [14033867.207951993, 3977355.3265721146], [14033940.49128036, 3977120.6627559136], [14034013.774608184, 3976886.0037188698], [14034074.84404724, 3976651.349460947], [14034148.127375823, 3976431.365434353], [14034233.624591732, 3976211.385607544], [14034306.907918751, 3975976.745086343], [14034380.191247718, 3975756.7739374605], [14034465.688463435, 3975522.1426732005], [14034563.399566704, 3975302.1802014867], [14034636.682894846, 3975067.5581912696], [14034722.18011089, 3974862.2678504367], [14034819.89121484, 3974627.654795317], [14034905.388430584, 3974407.7093927874], [14035015.313421464, 3974187.7681863704], [14035100.810637798, 3973967.831176343], [14035198.52174194, 3973747.8983624075], [14035296.232845142, 3973527.9697442516], [14035406.157837268, 3973322.706818716], [14035503.868941093, 3973102.7863105624], [14035564.938380616, 3972970.836018713], [14035430.585612448, 3972882.8699968406], [14035051.955086311, 3972589.6547698523], [14034685.538446855, 3972281.7868043673], [14034306.907918967, 3971988.5868595946], [14033928.277392257, 3971680.7349405857], [14033561.860752953, 3971358.232219437], [14033207.658001786, 3971050.3971231086], [14032865.66913857, 3970727.9120247746], [14032511.46638746, 3970376.1203815714], [14032169.477523897, 3970053.654131018], [14031851.916436315, 3969701.8830453446], [14031522.141461194, 3969350.1226848057], [14031192.36648596, 3969013.029068952], [14030874.80539861, 3968646.634131671], [14030569.4581991, 3968265.5957370186], [14030276.324888123, 3967899.224527907], [14029970.977688076, 3967518.2108068704], [14029677.844376301, 3967137.2096621543], [14029409.138841135, 3966756.2210914562], [14029140.433306115, 3966360.592421976], [14028871.727769978, 3965964.977308897], [14028737.375003058, 3965759.848882349], [14028615.236123221, 3965554.724099639], [14028480.883355275, 3965349.6029609945], [14028358.744475357, 3965144.4854657836], [14028224.391707785, 3964939.371614421], [14028102.252828015, 3964734.2614048333], [14027980.11394778, 3964529.1548381275], [14027857.975069236, 3964324.051914157], [14027760.26396438, 3964104.302822176], [14027638.125085097, 3963899.207441546], [14027528.200093549, 3963679.4664326143], [14027406.061214069, 3963474.378595102], [14027308.350109257, 3963254.6456658943], [14027198.425118413, 3963049.5653696535], [14027088.500126269, 3962815.1923459424], [14027003.00291053, 3962610.11984976], [14026893.077918677, 3962390.4033569284], [14026795.366814636, 3962170.6910431627], [14026697.655711418, 3961965.629985329], [14026599.944607599, 3961731.278946997], [14026502.233502936, 3961511.5791640463], [14026416.736287547, 3961291.883556766], [14026331.239071513, 3961072.192127874], [14026245.741855916, 3960837.859204179], [14026148.030752573, 3960603.531032333], [14026050.319648793, 3960515.6591924117], [14025696.116896924, 3960208.113014822], [14025354.12803417, 3959885.930555696], [14024987.711394375, 3959549.113039522], [14024645.722530954, 3959226.948944662], [14024291.51977964, 3958890.150626061], [14023961.74480434, 3958538.7193601266], [14023644.183716903, 3958172.6564824986], [14023314.408742214, 3957835.8888684427], [14023009.061542125, 3957469.8482367387], [14022703.714343427, 3957103.8191892453], [14022398.367143923, 3956723.1612672796], [14022117.447720738, 3956342.515870507], [14021812.100520544, 3955947.2435213765], [14021543.394986114, 3955566.6236525774], [14021250.261673959, 3955171.377811075], [14020993.770026248, 3954776.145468736], [14020859.417258942, 3954556.577778357], [14020737.27837941, 3954366.2891494785], [14020602.925611844, 3954176.0036486983], [14020480.786732143, 3953956.4473441243], [14020370.861740284, 3953751.53188359], [14020248.722860433, 3953546.620051208], [14020126.583980937, 3953341.7118456624], [14020004.445100708, 3953122.171365647], [14019882.306221237, 3952917.270673172], [14019784.595117368, 3952697.7382422695], [14019662.456236959, 3952492.8450626153], [14019552.531245224, 3952273.3206794215], [14019442.606254242, 3952068.435010681], [14019344.89515069, 3951834.2844000966], [14019234.97015812, 3951629.406499703], [14019137.259054784, 3951424.532224624], [14019027.33406315, 3951190.394634004], [14018941.836846726, 3950985.528125728], [14018831.911855552, 3950751.3994105365], [14018734.200752072, 3950546.540667035], [14018648.703535682, 3950312.4208263634], [14018563.206319205, 3950092.937773037], [14018465.495216299, 3949858.827100896], [14018379.99800022, 3949639.352642628], [14018306.71467212, 3949405.251136331], [14018209.003568063, 3949185.785271415], [14018135.72024025, 3948951.692931064], [14018062.43691314, 3948732.2356575206], [14017989.153585482, 3948498.1524819196], [14017915.87025767, 3948278.7037986964], [14017842.586929433, 3948044.6297840844], [14017757.089713955, 3947825.1896894635], [14017696.02027415, 3947591.124836192], [14017622.736946309, 3947371.6933298754], [14017561.667506203, 3947123.0093109384], [14017500.598066194, 3946888.958639055], [14017451.742514167, 3946654.9126930023], [14017390.673074305, 3946420.8714734227], [14017329.603633871, 3946157.5807487303], [14017195.25086627, 3945791.9091077414], [14017109.753650622, 3945557.885310957], [14017036.470322493, 3945338.4922908037], [14016963.186994748, 3945104.477646201], [14016889.903667673, 3944885.0932069574], [14016816.620339664, 3944651.0877120267], [14016743.337011732, 3944431.7118500136], [14016682.267571904, 3944197.715505999], [14016621.198132234, 3943963.7238824223], [14016547.914804032, 3943729.736980628], [14016486.845363976, 3943510.378546276], [14016425.775924595, 3943261.7773378296], [14016364.70648495, 3943042.427753915], [14016315.850932516, 3942793.8365748394], [14016254.781492097, 3942574.4958398864], [14016193.712052723, 3942325.9146883073], [14016157.070389032, 3942106.582801053], [14016108.214837069, 3941858.0116748144], [14016059.359284986, 3941624.0672444003], [14016034.931508765, 3941390.1275309804], [14015986.075957134, 3941141.571752944], [14015937.22040512, 3940922.2622533315], [14015912.792629696, 3940673.7164975833], [14015876.150965236, 3940439.7959427596], [14015839.509301143, 3940191.260519465], [14015802.867637865, 3939971.9689781107], [14015790.653749412, 3939723.443572689], [14015754.012085313, 3939489.542170439], [14015729.584309753, 3939241.0270946748], [14015717.37042194, 3939007.1354134823], [14015680.728758091, 3938758.630664511], [14015668.514870305, 3938510.131235958], [14015656.300982298, 3938290.871450771], [14015644.087093962, 3938042.3820334156], [14015644.087093726, 3937808.5144985737], [14015619.659317939, 3937560.035403366], [14015619.659317758, 3937326.1775837634], [14015607.445430323, 3937077.708810477], [14015607.445430165, 3936843.86070301], [14015607.44543046, 3936610.017304439], [14015607.445429819, 3936361.563852228], [14015619.659318132, 3936127.730163849], [14015619.65931785, 3935879.2870282456], [14015619.659318443, 3935864.672891335], [14015644.087093579, 3935645.4630488665], [14015644.087094065, 3935397.030227534], [14015656.300982174, 3935163.215955291], [14015668.51486993, 3934914.7934458014], [14015680.728758322, 3934680.988878479], [14015717.370422252, 3934432.576680492], [14015729.584309453, 3934213.393858009], [14015754.012085263, 3933964.991656822], [14015790.653749326, 3933716.5947647644], [14015802.867637549, 3933482.8143059933], [14015839.509301828, 3933234.4277204718], [14015863.937076895, 3933000.6569608934], [14015912.79262938, 3932766.8909023306], [14015937.220405562, 3932533.1295448784], [14015986.075957565, 3932299.3728892817], [14016010.503732808, 3932065.6209347122], [14016059.359284472, 3931817.2646324276], [14016108.214836905, 3931583.522371577], [14016169.284276439, 3931349.784810239], [14016205.925940333, 3931101.4438007176], [14016254.781492744, 3930882.3237842843], [14016315.85093268, 3930633.992758967], [14016364.706484258, 3930414.8815508736], [14016425.77592429, 3930166.5605083024], [14016486.845364062, 3929947.45810794], [14016547.91480444, 3929699.147045077], [14016621.19813177, 3929480.053450812], [14016682.267571429, 3929246.358166313], [14016743.337011438, 3929012.667577336], [14016816.620339306, 3928793.5869140886], [14016889.903667254, 3928545.3004834815], [14016963.186994506, 3928326.228621952], [14017036.470323365, 3928092.5565168317], [14017109.753650554, 3927873.4931812496], [14017195.250866888, 3927639.8301689653], [14017280.748082507, 3927420.7753582653], [14017354.031410536, 3927187.121436862], [14017451.742513658, 3926982.678106103], [14017537.239729747, 3926749.032981505], [14017622.736945918, 3926529.994938449], [14017720.448049057, 3926296.358903417], [14017818.159152932, 3926091.931220958], [14017915.870256566, 3925858.3039796036], [14018001.367473086, 3925653.883990288], [14018111.292464526, 3925420.265541152], [14018209.00356852, 3925201.2525036694], [14018318.928560698, 3924996.844052992], [14018428.853552194, 3924763.2387903365], [14018526.564656094, 3924558.8380302167], [14018636.489648066, 3924339.8411988374], [14018758.628527954, 3924135.447872815], [14018856.339631462, 3923916.459004483], [14018978.478511153, 3923712.073110865], [14019100.617391173, 3923493.0922050807], [14019222.756270064, 3923288.713741847], [14019344.895150287, 3923084.338865018], [14019467.034029689, 3922879.9675755682], [14019589.172909968, 3922661.0023167264], [14019723.525677131, 3922471.235755037], [14019833.45066894, 3922252.2781808744], [14019980.017325126, 3922062.518278752], [14020114.370092534, 3921872.7614680813], [14020383.075627644, 3921464.06499056], [14020651.781162936, 3921084.5739564244], [14020932.700586699, 3920690.500196857], [14021225.833898693, 3920311.0343588293], [14021518.967209466, 3919931.5808786163], [14021824.31440906, 3919552.1397545147], [14021873.169961166, 3919508.3588811457], [14022007.522728465, 3919158.1178169283], [14022117.447720494, 3918939.222496516], [14022215.158823974, 3918720.331287067], [14022312.869927935, 3918486.851860621], [14022410.581031999, 3918282.5611990155], [14022520.506023144, 3918063.6823207946], [14022618.217127495, 3917844.807552783], [14022728.14211945, 3917625.9368926086], [14022838.06711147, 3917421.6613179413], [14022960.205990292, 3917202.798602683], [14023057.917095127, 3916998.53044049], [14023180.055974487, 3916779.675667872], [14023302.194854327, 3916575.4149192595], [14023424.333733585, 3916356.568086974], [14023546.472613554, 3916166.904154751], [14023656.397605527, 3915948.0649889517], [14023778.536485165, 3915758.4077001447], [14023912.889252687, 3915539.5761998114], [14024035.028132746, 3915335.337170445], [14024303.73366759, 3914941.4577129018], [14024560.225315124, 3914547.5915551204], [14024853.358626463, 3914153.7386953356], [14025134.278049812, 3913759.899131608], [14025427.411361214, 3913380.6587829227], [14025720.544672925, 3913001.430759424], [14026025.891872894, 3912636.800052067], [14026272.928939708, 3912341.809856742], [14026312.49388151, 3787395.72363925], [13961312.04903013, 3787395.72363925], [13947389.033479117, 3802232.5362782693], [13947389.033479117, 3802232.5362782683], [13822584.485054709, 3935228.2723445967], [13818455.7465445, 3939627.988734125], [13804540.810181938, 4028802.026185181], [13817531.794596978, 4163881.1427735523], [13817531.794716273, 4163881.144013962], [13817590.293393573, 4163976.6556012244]]]]}}]} \ No newline at end of file diff --git a/prediction/data/zones/특정어업수역Ⅳ.json b/prediction/data/zones/특정어업수역Ⅳ.json new file mode 100644 index 0000000..1ce6f88 --- /dev/null +++ b/prediction/data/zones/특정어업수역Ⅳ.json @@ -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]]]]}}]} \ No newline at end of file From e21d2a74e53e3f3f5bfd9681350403609a6b305f Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:49:00 +0900 Subject: [PATCH 13/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3f935f5..053756a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,7 @@ ## [Unreleased] ### 추가 +- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon) - 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가 - 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단 - 어구/어망 카테고리 신설 + 모선 연결선 시각화 @@ -15,6 +16,9 @@ - 정부기관 건물 레이어 (GovBuildingLayer) - CCTV 프록시 컨트롤러 +### 수정 +- 중국어선감시 연결선 폭발 수정: 부분매칭 제거 + 거리제한 10NM + 마커 상한 200개 + ### 변경 - 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망 - 오른쪽 패널 접기/펼치기 기능 From 5a93b4af25f992fb6ae7d0e1a47ade1b1dae24c2 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 12:52:07 +0900 Subject: [PATCH 14/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 053756a..d2b34c5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,38 +4,18 @@ ## [Unreleased] +## [2026-03-20] + ### 추가 - 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon) -- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가 -- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단 -- 어구/어망 카테고리 신설 + 모선 연결선 시각화 -- 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본) -- 이란 주변국 시설 레이어 (MEFacilityLayer 35개소) -- 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영 -- 한국 해군부대 10개소, 항만, 풍력발전단지, 북한 발사대/미사일 이벤트 레이어 -- 정부기관 건물 레이어 (GovBuildingLayer) -- CCTV 프록시 컨트롤러 -### 수정 -- 중국어선감시 연결선 폭발 수정: 부분매칭 제거 + 거리제한 10NM + 마커 상한 200개 - -### 변경 -- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망 -- 오른쪽 패널 접기/펼치기 기능 -- 센서차트 기본 숨김 -- CCTV 레이어 리팩토링 - -## [2026-03-19.2] +## [2026-03-19] ### 추가 - OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신) ### 변경 - OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304) - -## [2026-03-19] - -### 변경 - 인라인 CSS 정리 — 공통 클래스 추출 + Tailwind 전환 ### 수정 From e82b2d77e7e4f37b1b1c6d6065bd6ffad973897c Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:28:50 +0900 Subject: [PATCH 15/46] =?UTF-8?q?feat:=20Python=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20+?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=97=B0=EB=8F=99=20=E2=80=94=20Backen?= =?UTF-8?q?d=20API=20=EB=B3=B5=EC=9B=90=20+=20DB=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20+=20=ED=86=B5=EA=B3=84=ED=8C=A8=EB=84=90=20+=20?= =?UTF-8?q?=EC=9C=84=ED=97=98=EB=8F=84=20=EB=A7=88=EC=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../analysis/VesselAnalysisController.java | 35 +++ .../domain/analysis/VesselAnalysisDto.java | 148 ++++++++++ .../domain/analysis/VesselAnalysisResult.java | 97 ++++++ .../VesselAnalysisResultRepository.java | 11 + .../analysis/VesselAnalysisService.java | 47 +++ database/migration/005_vessel_analysis.sql | 30 ++ frontend/src/App.tsx | 6 + .../src/components/korea/AnalysisOverlay.tsx | 279 ++++++++++++++++++ .../components/korea/AnalysisStatsPanel.tsx | 175 +++++++++++ .../korea/ChineseFishingOverlay.tsx | 37 ++- frontend/src/components/korea/KoreaMap.tsx | 30 +- frontend/src/hooks/useKoreaFilters.ts | 17 +- frontend/src/hooks/useVesselAnalysis.ts | 114 +++++++ frontend/src/services/vesselAnalysis.ts | 12 + frontend/src/types.ts | 28 ++ 15 files changed, 1056 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java create mode 100644 database/migration/005_vessel_analysis.sql create mode 100644 frontend/src/components/korea/AnalysisOverlay.tsx create mode 100644 frontend/src/components/korea/AnalysisStatsPanel.tsx create mode 100644 frontend/src/hooks/useVesselAnalysis.ts create mode 100644 frontend/src/services/vesselAnalysis.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java new file mode 100644 index 0000000..b6fd0a7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -0,0 +1,35 @@ +package gc.mda.kcg.domain.analysis; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vessel-analysis") +@RequiredArgsConstructor +public class VesselAnalysisController { + + private final VesselAnalysisService vesselAnalysisService; + + /** + * 최근 선박 분석 결과 조회 + * @param region 지역 필터 (향후 확장용, 현재 미사용) + */ + @GetMapping + public ResponseEntity> getVesselAnalysis( + @RequestParam(required = false) String region) { + + List results = vesselAnalysisService.getLatestResults(); + + return ResponseEntity.ok(Map.of( + "count", results.size(), + "items", results + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java new file mode 100644 index 0000000..a8d2e52 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java @@ -0,0 +1,148 @@ +package gc.mda.kcg.domain.analysis; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VesselAnalysisDto { + + private String mmsi; + private String timestamp; + private Classification classification; + private Algorithms algorithms; + private Map features; + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Classification { + private String vesselType; + private Double confidence; + private Double fishingPct; + private Integer clusterId; + private String season; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Algorithms { + private LocationInfo location; + private ActivityInfo activity; + private DarkVesselInfo darkVessel; + private GpsSpoofingInfo gpsSpoofing; + private ClusterInfo cluster; + private FleetRoleInfo fleetRole; + private RiskScoreInfo riskScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class LocationInfo { + private String zone; + private Double distToBaselineNm; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ActivityInfo { + private String state; + private Double ucafScore; + private Double ucftScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DarkVesselInfo { + private Boolean isDark; + private Integer gapDurationMin; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class GpsSpoofingInfo { + private Double spoofingScore; + private Double bd09OffsetM; + private Integer speedJumpCount; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ClusterInfo { + private Integer clusterId; + private Integer clusterSize; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FleetRoleInfo { + private Boolean isLeader; + private String role; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RiskScoreInfo { + private Integer score; + private String level; + } + + public static VesselAnalysisDto from(VesselAnalysisResult r) { + return VesselAnalysisDto.builder() + .mmsi(r.getMmsi()) + .timestamp(r.getTimestamp().toString()) + .classification(Classification.builder() + .vesselType(r.getVesselType()) + .confidence(r.getConfidence()) + .fishingPct(r.getFishingPct()) + .clusterId(r.getClusterId()) + .season(r.getSeason()) + .build()) + .algorithms(Algorithms.builder() + .location(LocationInfo.builder() + .zone(r.getZone()) + .distToBaselineNm(r.getDistToBaselineNm()) + .build()) + .activity(ActivityInfo.builder() + .state(r.getActivityState()) + .ucafScore(r.getUcafScore()) + .ucftScore(r.getUcftScore()) + .build()) + .darkVessel(DarkVesselInfo.builder() + .isDark(r.getIsDark()) + .gapDurationMin(r.getGapDurationMin()) + .build()) + .gpsSpoofing(GpsSpoofingInfo.builder() + .spoofingScore(r.getSpoofingScore()) + .bd09OffsetM(r.getBd09OffsetM()) + .speedJumpCount(r.getSpeedJumpCount()) + .build()) + .cluster(ClusterInfo.builder() + .clusterId(r.getClusterId()) + .clusterSize(r.getClusterSize()) + .build()) + .fleetRole(FleetRoleInfo.builder() + .isLeader(r.getIsLeader()) + .role(r.getFleetRole()) + .build()) + .riskScore(RiskScoreInfo.builder() + .score(r.getRiskScore()) + .level(r.getRiskLevel()) + .build()) + .build()) + .features(r.getFeatures()) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java new file mode 100644 index 0000000..6306d8f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -0,0 +1,97 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "vessel_analysis_results", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VesselAnalysisResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 15) + private String mmsi; + + @Column(nullable = false) + private Instant timestamp; + + @Column(length = 20) + private String vesselType; + + private Double confidence; + + private Double fishingPct; + + private Integer clusterId; + + @Column(length = 10) + private String season; + + @Column(length = 20) + private String zone; + + private Double distToBaselineNm; + + @Column(length = 20) + private String activityState; + + private Double ucafScore; + + private Double ucftScore; + + @Column(nullable = false) + private Boolean isDark; + + private Integer gapDurationMin; + + private Double spoofingScore; + + private Double bd09OffsetM; + + private Integer speedJumpCount; + + private Integer clusterSize; + + @Column(nullable = false) + private Boolean isLeader; + + @Column(length = 20) + private String fleetRole; + + private Integer riskScore; + + @Column(length = 20) + private String riskLevel; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map features; + + @Column(nullable = false) + private Instant analyzedAt; + + @PrePersist + protected void onCreate() { + if (analyzedAt == null) { + analyzedAt = Instant.now(); + } + if (isDark == null) { + isDark = false; + } + if (isLeader == null) { + isLeader = false; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java new file mode 100644 index 0000000..17f117b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.analysis; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface VesselAnalysisResultRepository extends JpaRepository { + + List findByTimestampAfter(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java new file mode 100644 index 0000000..9e78162 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -0,0 +1,47 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VesselAnalysisService { + + private static final int RECENT_MINUTES = 10; + + private final VesselAnalysisResultRepository repository; + private final CacheManager cacheManager; + + /** + * 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + */ + @SuppressWarnings("unchecked") + public List getLatestResults() { + Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + + Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES); + List results = repository.findByTimestampAfter(since) + .stream() + .map(VesselAnalysisDto::from) + .toList(); + + if (cache != null) { + cache.put("data", results); + } + + return results; + } +} diff --git a/database/migration/005_vessel_analysis.sql b/database/migration/005_vessel_analysis.sql new file mode 100644 index 0000000..82650f3 --- /dev/null +++ b/database/migration/005_vessel_analysis.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS kcg.vessel_analysis_results ( + id BIGSERIAL PRIMARY KEY, + mmsi VARCHAR(15) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + vessel_type VARCHAR(20), + confidence DOUBLE PRECISION, + fishing_pct DOUBLE PRECISION, + cluster_id INTEGER, + season VARCHAR(10), + zone VARCHAR(20), + dist_to_baseline_nm DOUBLE PRECISION, + activity_state VARCHAR(20), + ucaf_score DOUBLE PRECISION, + ucft_score DOUBLE PRECISION, + is_dark BOOLEAN DEFAULT FALSE, + gap_duration_min INTEGER, + spoofing_score DOUBLE PRECISION, + bd09_offset_m DOUBLE PRECISION, + speed_jump_count INTEGER, + cluster_size INTEGER, + is_leader BOOLEAN DEFAULT FALSE, + fleet_role VARCHAR(20), + risk_score INTEGER, + risk_level VARCHAR(20), + features JSONB, + analyzed_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_mmsi ON kcg.vessel_analysis_results(mmsi); +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_timestamp ON kcg.vessel_analysis_results(timestamp DESC); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f004236..e73a25f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,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'; @@ -186,11 +187,15 @@ 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, ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { @@ -560,6 +565,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { cableWatchSuspects={koreaFiltersResult.cableWatchSuspects} dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects} dokdoAlerts={koreaFiltersResult.dokdoAlerts} + vesselAnalysis={vesselAnalysis} />
= { + CRITICAL: '#ef4444', + HIGH: '#f97316', + MEDIUM: '#eab308', + LOW: '#22c55e', +}; + +const RISK_PRIORITY: Record = { + CRITICAL: 0, + HIGH: 1, + MEDIUM: 2, + LOW: 3, +}; + +interface Props { + ships: Ship[]; + analysisMap: Map; + clusters: Map; + activeFilter: string | null; +} + +interface AnalyzedShip { + ship: Ship; + dto: VesselAnalysisDto; +} + +/** 위험도 펄스 애니메이션 인라인 스타일 */ +function riskPulseStyle(riskLevel: string): React.CSSProperties { + const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; + return { + width: 10, + height: 10, + borderRadius: '50%', + backgroundColor: color, + boxShadow: `0 0 6px 2px ${color}88`, + animation: riskLevel === 'CRITICAL' ? 'pulse 1s infinite' : undefined, + pointerEvents: 'none', + }; +} + +export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: Props) { + // analysisMap에 있는 선박만 대상 + const analyzedShips: AnalyzedShip[] = useMemo(() => { + return ships + .filter(s => analysisMap.has(s.mmsi)) + .map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! })); + }, [ships, analysisMap]); + + // 위험도 마커 — CRITICAL/HIGH 우선 최대 100개 + const riskMarkers = useMemo(() => { + return 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); + }, [analyzedShips]); + + // 다크베셀 마커 + const darkVesselMarkers = useMemo(() => { + if (activeFilter !== 'darkVessel') return []; + return analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark); + }, [analyzedShips, activeFilter]); + + // GPS 스푸핑 마커 + const spoofingMarkers = useMemo(() => { + return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5); + }, [analyzedShips]); + + // 선단 연결선 GeoJSON (cnFishing 필터 ON일 때) + const clusterLineGeoJson = useMemo(() => { + if (activeFilter !== 'cnFishing') { + return { type: 'FeatureCollection' as const, features: [] }; + } + + const features: GeoJSON.Feature[] = []; + + for (const [clusterId, mmsiList] of clusters) { + if (mmsiList.length < 2) continue; + + // cluster 내 선박 위치 조회 + const clusterShips = mmsiList + .map(mmsi => { + const ship = ships.find(s => s.mmsi === mmsi); + return ship ?? null; + }) + .filter((s): s is Ship => s !== null); + + if (clusterShips.length < 2) continue; + + // leader 찾기 + const leaderMmsi = mmsiList.find(mmsi => { + const dto = analysisMap.get(mmsi); + return dto?.algorithms.fleetRole.isLeader === true; + }); + + // leader → 각 member 연결선 + if (leaderMmsi) { + const leaderShip = ships.find(s => s.mmsi === leaderMmsi); + if (leaderShip) { + for (const memberShip of clusterShips) { + if (memberShip.mmsi === leaderMmsi) continue; + features.push({ + type: 'Feature' as const, + properties: { clusterId, leaderMmsi, memberMmsi: memberShip.mmsi }, + geometry: { + type: 'LineString' as const, + coordinates: [ + [leaderShip.lng, leaderShip.lat], + [memberShip.lng, memberShip.lat], + ], + }, + }); + } + } + } else { + // leader 없으면 순차 연결 + for (let i = 0; i < clusterShips.length - 1; i++) { + features.push({ + type: 'Feature' as const, + properties: { clusterId, leaderMmsi: null, memberMmsi: clusterShips[i + 1].mmsi }, + geometry: { + type: 'LineString' as const, + coordinates: [ + [clusterShips[i].lng, clusterShips[i].lat], + [clusterShips[i + 1].lng, clusterShips[i + 1].lat], + ], + }, + }); + } + } + } + + return { type: 'FeatureCollection' as const, features }; + }, [activeFilter, clusters, ships, analysisMap]); + + // leader 선박 목록 (cnFishing 필터 ON) + const leaderShips = useMemo(() => { + if (activeFilter !== 'cnFishing') return []; + return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader); + }, [analyzedShips, activeFilter]); + + return ( + <> + {/* 선단 연결선 */} + {clusterLineGeoJson.features.length > 0 && ( + + + + )} + + {/* 위험도 마커 */} + {riskMarkers.map(({ ship, dto }) => { + const level = dto.algorithms.riskScore.level; + const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; + return ( + +
+ {/* 삼각형 아이콘 */} +
+ {/* 위험도 텍스트 */} +
+ {level} +
+
+ + ); + })} + + {/* CRITICAL 펄스 오버레이 */} + {riskMarkers + .filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL') + .map(({ ship }) => ( + +
+ + ))} + + {/* 다크베셀 마커 */} + {darkVesselMarkers.map(({ ship, dto }) => { + const gapMin = dto.algorithms.darkVessel.gapDurationMin; + return ( + +
+ {/* 보라 점선 원 */} +
+ {/* gap 라벨 */} +
+ {gapMin > 0 ? `${Math.round(gapMin)}분` : 'DARK'} +
+
+ + ); + })} + + {/* GPS 스푸핑 배지 */} + {spoofingMarkers.map(({ ship }) => ( + +
+ GPS +
+
+ ))} + + {/* 선단 leader 별 아이콘 */} + {leaderShips.map(({ ship }) => ( + +
+ ★ +
+
+ ))} + + ); +} diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx new file mode 100644 index 0000000..016d6b1 --- /dev/null +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -0,0 +1,175 @@ +import { useState, useMemo } from 'react'; +import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; + +interface Props { + stats: AnalysisStats; + lastUpdated: number; + isLoading: boolean; +} + +/** 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}`; +} + +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { + const [expanded, setExpanded] = useState(true); + + const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + + const panelStyle: React.CSSProperties = { + position: 'absolute', + top: 60, + right: 10, + zIndex: 10, + minWidth: 160, + 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', + }; + + 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', + }; + + 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', + }; + + 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: 6, + justifyContent: 'space-between', + marginTop: 4, + }; + + const riskBadgeStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 2, + color: '#cbd5e1', + fontSize: 10, + }; + + return ( +
+ {/* 헤더 */} +
+
+ AI 분석 + {isLoading && ( + 로딩중... + )} +
+
+ {formatTime(lastUpdated)} + +
+
+ + {/* 본문 */} + {expanded && ( +
+ {isEmpty ? ( +
+ 분석 데이터 없음 +
+ ) : ( + <> +
+ 전체 + {stats.total} +
+
+ 다크베셀 + {stats.dark} +
+
+ GPS스푸핑 + {stats.spoofing} +
+
+ 선단수 + {stats.clusterCount} +
+ +
+ + {/* 위험도 수치 행 */} +
+
+ 🔴 + {stats.critical} +
+
+ 🟠 + {stats.high} +
+
+ 🟡 + {stats.medium} +
+
+ 🟢 + {stats.low} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/korea/ChineseFishingOverlay.tsx b/frontend/src/components/korea/ChineseFishingOverlay.tsx index 64eb937..7579f8e 100644 --- a/frontend/src/components/korea/ChineseFishingOverlay.tsx +++ b/frontend/src/components/korea/ChineseFishingOverlay.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Marker, Source, Layer } from 'react-map-gl/maplibre'; -import type { Ship } from '../../types'; +import type { Ship, VesselAnalysisDto } from '../../types'; import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis'; import type { FishingGearType } from '../../utils/fishingAnalysis'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -79,9 +79,10 @@ interface GearToParentLink { interface Props { ships: Ship[]; + analysisMap?: Map; } -export function ChineseFishingOverlay({ ships }: Props) { +export function ChineseFishingOverlay({ ships, analysisMap }: Props) { // 중국 어선만 필터링 const chineseFishing = useMemo(() => { return ships.filter(s => { @@ -91,17 +92,43 @@ export function ChineseFishingOverlay({ ships }: Props) { }); }, [ships]); + // Python fleet_role → 표시용 role 매핑 + const resolveRole = (s: Ship): { role: string; roleKo: string; color: string } => { + const dto = analysisMap?.get(s.mmsi); + if (dto) { + const fleetRole = dto.algorithms.fleetRole.role; + const riskLevel = dto.algorithms.riskScore.level; + if (fleetRole === 'LEADER') { + return { role: 'PT', roleKo: '본선', color: riskLevel === 'CRITICAL' ? '#ef4444' : '#f97316' }; + } + if (fleetRole === 'MEMBER') { + return { role: 'PT-S', roleKo: '부속', color: '#fb923c' }; + } + } + return estimateRole(s); + }; + // 조업 분석 결과 const analyzed = useMemo(() => { return chineseFishing.map(s => ({ ship: s, analysis: analyzeFishing(s), - role: estimateRole(s), + role: resolveRole(s), })); - }, [chineseFishing]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chineseFishing, analysisMap]); // 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척) - const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating).slice(0, 100), [analyzed]); + // Python activity_state === 'FISHING'인 선박도 조업 중으로 간주 + const operating = useMemo(() => { + return analyzed + .filter(a => { + if (a.analysis.isOperating) return true; + const dto = analysisMap?.get(a.ship.mmsi); + return dto?.algorithms.activity.state === 'FISHING'; + }) + .slice(0, 100); + }, [analyzed, analysisMap]); // 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선) const gearLinks: GearToParentLink[] = useMemo(() => { diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 807eb55..cea50a3 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -21,10 +21,13 @@ import { GovBuildingLayer } from './GovBuildingLayer'; import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; +import { AnalysisOverlay } from './AnalysisOverlay'; +import { AnalysisStatsPanel } from './AnalysisStatsPanel'; 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 'maplibre-gl/dist/maplibre-gl.css'; @@ -49,6 +52,7 @@ interface Props { cableWatchSuspects: Set; dokdoWatchSuspects: Set; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; + vesselAnalysis?: UseVesselAnalysisResult; } // MarineTraffic-style: satellite + dark ocean + nautical overlay @@ -121,7 +125,7 @@ const FILTER_I18N_KEY: Record = { ferryWatch: 'filters.ferryWatchMonitor', }; -export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) { +export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -268,7 +272,20 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.govBuildings && } {layers.nkLaunch && } {layers.nkMissile && } - {layers.cnFishing && } + {layers.cnFishing && } + {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( + + )} {layers.airports && } {layers.coastGuard && } {layers.navWarning && } @@ -330,6 +347,15 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre ))}
)} + + {/* AI Analysis Stats Panel */} + {vesselAnalysis && vesselAnalysis.stats.total > 0 && ( + + )} ); } diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index 135da1d..2bbec27 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -1,7 +1,7 @@ import { useState, useMemo, useRef } from 'react'; import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; -import type { Ship } from '../types'; +import type { Ship, VesselAnalysisDto } from '../types'; interface KoreaFilters { illegalFishing: boolean; @@ -41,6 +41,7 @@ export function useKoreaFilters( koreaShips: Ship[], visibleShips: Ship[], currentTime: number, + analysisMap?: Map, ): UseKoreaFiltersResult { const [filters, setFilters] = useState({ illegalFishing: false, @@ -190,8 +191,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(() => { @@ -298,6 +308,7 @@ export function useKoreaFilters( return visibleShips.filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true; + if (filters.illegalFishing && (analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'CRITICAL' || analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'HIGH')) 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; @@ -305,7 +316,7 @@ export function useKoreaFilters( if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; return false; }); - }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]); return { filters, diff --git a/frontend/src/hooks/useVesselAnalysis.ts b/frontend/src/hooks/useVesselAnalysis.ts new file mode 100644 index 0000000..ea53b32 --- /dev/null +++ b/frontend/src/hooks/useVesselAnalysis.ts @@ -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; + stats: AnalysisStats; + clusters: Map; + 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>(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(); + + 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 => { + const result = new Map(); + 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 }; +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts new file mode 100644 index 0000000..0399cb8 --- /dev/null +++ b/frontend/src/services/vesselAnalysis.ts @@ -0,0 +1,12 @@ +import type { VesselAnalysisDto } from '../types'; + +const API_BASE = '/api/kcg'; + +export async function fetchVesselAnalysis(): Promise { + 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 ?? []; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 458e7fe..a873fa9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -147,3 +147,31 @@ export interface LayerVisibility { } export type AppMode = 'replay' | 'live'; + +// 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 interface VesselAnalysisDto { + mmsi: string; + 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; +} From de36958fa06c14b3d9c6f3ff36fce914e3c6aaf3 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:29:35 +0900 Subject: [PATCH 16/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d2b34c5..bd98d20 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 추가 +- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 +- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 +- 불법어선 필터에 Python risk_level(CRITICAL/HIGH) 선박 자동 포함 +- 다크베셀 필터에 Python is_dark 감지 결과 합집합 +- 중국어선감시에 Python fleet_role(LEADER/MEMBER) 역할 우선 표시 +- Backend vessel-analysis REST API 복원 (JPA + Caffeine 캐시) +- DB vessel_analysis_results 테이블 생성 (005 마이그레이션) + ## [2026-03-20] ### 추가 From 5e60c8dba4ec7daf5d890c73547c14db0fcc6fb2 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:30:40 +0900 Subject: [PATCH 17/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-20.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bd98d20..8ab53e8 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,14 +4,13 @@ ## [Unreleased] +## [2026-03-20.2] + ### 추가 - Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 - AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 -- 불법어선 필터에 Python risk_level(CRITICAL/HIGH) 선박 자동 포함 -- 다크베셀 필터에 Python is_dark 감지 결과 합집합 -- 중국어선감시에 Python fleet_role(LEADER/MEMBER) 역할 우선 표시 -- Backend vessel-analysis REST API 복원 (JPA + Caffeine 캐시) -- DB vessel_analysis_results 테이블 생성 (005 마이그레이션) +- 불법어선/다크베셀/중국어선감시 Python 분석 연동 +- Backend vessel-analysis REST API + DB 테이블 복원 ## [2026-03-20] From 67d817d0ba5c32be9fbe0851482717dbe867459e Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:33:44 +0900 Subject: [PATCH 18/46] =?UTF-8?q?fix:=20CacheConfig=EC=97=90=20VESSEL=5FAN?= =?UTF-8?q?ALYSIS=20=EC=83=81=EC=88=98=20=EB=88=84=EB=9D=BD=20=E2=80=94=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/main/java/gc/mda/kcg/config/CacheConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index 1d216f7..f2274a0 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -20,6 +20,7 @@ public class CacheConfig { public static final String SATELLITES = "satellites"; public static final String SEISMIC = "seismic"; public static final String PRESSURE = "pressure"; + public static final String VESSEL_ANALYSIS = "vessel-analysis"; @Bean public CacheManager cacheManager() { @@ -27,7 +28,8 @@ public class CacheConfig { AIRCRAFT_IRAN, AIRCRAFT_KOREA, OSINT_IRAN, OSINT_KOREA, SATELLITES, - SEISMIC, PRESSURE + SEISMIC, PRESSURE, + VESSEL_ANALYSIS ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) From 746ddb7111bbc85511b2ac76c799a4a2a2ff6869 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:40:16 +0900 Subject: [PATCH 19/46] =?UTF-8?q?fix:=20numpy=20float=20=E2=86=92=20Python?= =?UTF-8?q?=20native=20=EB=B3=80=ED=99=98=20=E2=80=94=20DB=20INSERT=20?= =?UTF-8?q?=EC=8B=9C=20np.float64=20=EC=A7=81=EB=A0=AC=ED=99=94=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- prediction/models/result.py | 56 ++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/prediction/models/result.py b/prediction/models/result.py index 9792351..e1680a5 100644 --- a/prediction/models/result.py +++ b/prediction/models/result.py @@ -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, ) From 4478b70cd8470a82ea25e750e6f4ede6093cb637 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:52:49 +0900 Subject: [PATCH 20/46] =?UTF-8?q?fix:=20=EB=B6=88=EB=B2=95=EC=96=B4?= =?UTF-8?q?=EC=84=A0=20=ED=95=84=ED=84=B0=EC=97=90=20=EB=B9=84=EC=96=B4?= =?UTF-8?q?=EC=84=A0=20=ED=8F=AC=ED=95=A8=EB=90=98=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=E2=80=94=20risk=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20fi?= =?UTF-8?q?shing=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=97=90=EB=A7=8C?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useKoreaFilters.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index 2bbec27..bdfcf2d 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -307,8 +307,11 @@ 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 && (analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'CRITICAL' || analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'HIGH')) return true; + if (filters.illegalFishing) { + if (mtCat === 'fishing' && s.flag !== 'KR') return true; + const riskLevel = analysisMap?.get(s.mmsi)?.algorithms.riskScore.level; + if (mtCat === 'fishing' && (riskLevel === 'CRITICAL' || riskLevel === 'HIGH')) 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; From f2a05f742f3026030b1db74c576616bbb8a640c1 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 13:58:04 +0900 Subject: [PATCH 21/46] =?UTF-8?q?fix:=20vessel-analysis=20API=20500=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20+=20=EB=B6=88=EB=B2=95=EC=96=B4=EC=84=A0?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EA=B8=B0=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JPA bd09OffsetM → @Column(name="bd09_offset_m") 매핑 추가 - chnPrmShip.ts 복원 (허가어선 조회 서비스 누락) - 불법어선: 영해/접속수역 침범 + risk HIGH+ 어선만 표시 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/analysis/VesselAnalysisResult.java | 1 + frontend/src/hooks/useKoreaFilters.ts | 12 ++++- frontend/src/services/chnPrmShip.ts | 54 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 frontend/src/services/chnPrmShip.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java index 6306d8f..0ba31d5 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -58,6 +58,7 @@ public class VesselAnalysisResult { private Double spoofingScore; + @Column(name = "bd09_offset_m") private Double bd09OffsetM; private Integer speedJumpCount; diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index bdfcf2d..2070a62 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -308,9 +308,17 @@ export function useKoreaFilters( return visibleShips.filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (filters.illegalFishing) { + const analysis = analysisMap?.get(s.mmsi); + if (analysis) { + // Python 분석: 영해/접속수역 침범 또는 위험도 HIGH+ 어선 + const zone = analysis.algorithms.location.zone; + const riskLevel = analysis.algorithms.riskScore.level; + const isThreat = zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE' + || riskLevel === 'CRITICAL' || riskLevel === 'HIGH'; + if (isThreat) return true; + } + // 비한국 어선 (기본 필터) if (mtCat === 'fishing' && s.flag !== 'KR') return true; - const riskLevel = analysisMap?.get(s.mmsi)?.algorithms.riskScore.level; - if (mtCat === 'fishing' && (riskLevel === 'CRITICAL' || riskLevel === 'HIGH')) return true; } if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true; diff --git a/frontend/src/services/chnPrmShip.ts b/frontend/src/services/chnPrmShip.ts new file mode 100644 index 0000000..1a4c890 --- /dev/null +++ b/frontend/src/services/chnPrmShip.ts @@ -0,0 +1,54 @@ +import type { ChnPrmShipInfo } from '../types'; + +const SIGNAL_BATCH_BASE = '/signal-batch'; +const CACHE_TTL_MS = 5 * 60_000; // 5분 + +let cachedList: ChnPrmShipInfo[] = []; +let cacheTime = 0; +let fetchPromise: Promise | null = null; + +async function fetchList(): Promise { + const now = Date.now(); + if (cachedList.length > 0 && now - cacheTime < CACHE_TTL_MS) { + return cachedList; + } + + if (fetchPromise) return fetchPromise; + + fetchPromise = (async () => { + try { + const res = await fetch( + `${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions?minutes=60`, + { headers: { accept: 'application/json' } }, + ); + if (!res.ok) return cachedList; + const json: unknown = await res.json(); + cachedList = Array.isArray(json) ? (json as ChnPrmShipInfo[]) : []; + cacheTime = Date.now(); + return cachedList; + } catch { + return cachedList; + } finally { + fetchPromise = null; + } + })(); + + return fetchPromise; +} + +/** mmsi로 허가어선 정보 조회 — 목록을 캐시하고 lookup */ +export async function lookupPermittedShip(mmsi: string): Promise { + const list = await fetchList(); + return list.find((s) => s.mmsi === mmsi) ?? null; +} + +/** 허가어선 mmsi Set (빠른 조회용) */ +export async function getPermittedMmsiSet(): Promise> { + const list = await fetchList(); + return new Set(list.map((s) => s.mmsi)); +} + +/** 캐시 강제 갱신 */ +export function invalidateCache(): void { + cacheTime = 0; +} From af02ad12ffd807b07b3590389d84b92de52978d8 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 14:05:35 +0900 Subject: [PATCH 22/46] =?UTF-8?q?feat:=20=EB=B6=88=EB=B2=95=EC=96=B4?= =?UTF-8?q?=EC=84=A0=20=ED=95=84=ED=84=B0=20=EC=8B=9C=20=EC=88=98=EC=97=AD?= =?UTF-8?q?=20=ED=8F=B4=EB=A6=AC=EA=B3=A4=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20+=20=EC=84=A0=EB=B0=95=20=EB=A7=88=EC=BB=A4=20?= =?UTF-8?q?=EA=B0=80=EC=8B=9C=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WGS84 사전 변환 GeoJSON 생성 (런타임 변환 제거) - FishingZoneLayer: 수역별 색상 fill/line + 이름 라벨 - AnalysisOverlay: 마커 크기 확대, 한글 라벨, 선박명 표시 - fishingAnalysis.ts: EPSG:3857 변환 로직 제거, WGS84 JSON 직접 사용 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/AnalysisOverlay.tsx | 122 +++++++++++++----- .../src/components/korea/FishingZoneLayer.tsx | 93 +++++++++++++ frontend/src/components/korea/KoreaMap.tsx | 2 + .../src/data/zones/fishing-zones-wgs84.json | 1 + frontend/src/utils/fishingAnalysis.ts | 51 +++----- 5 files changed, 206 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/korea/FishingZoneLayer.tsx create mode 100644 frontend/src/data/zones/fishing-zones-wgs84.json diff --git a/frontend/src/components/korea/AnalysisOverlay.tsx b/frontend/src/components/korea/AnalysisOverlay.tsx index e8b5770..a24b655 100644 --- a/frontend/src/components/korea/AnalysisOverlay.tsx +++ b/frontend/src/components/korea/AnalysisOverlay.tsx @@ -9,6 +9,19 @@ const RISK_COLORS: Record = { LOW: '#22c55e', }; +const RISK_LABEL: Record = { + CRITICAL: '긴급', + HIGH: '경고', + MEDIUM: '주의', + LOW: '정상', +}; + +const RISK_MARKER_SIZE: Record = { + CRITICAL: 18, + HIGH: 14, + MEDIUM: 12, +}; + const RISK_PRIORITY: Record = { CRITICAL: 0, HIGH: 1, @@ -31,9 +44,10 @@ interface AnalyzedShip { /** 위험도 펄스 애니메이션 인라인 스타일 */ function riskPulseStyle(riskLevel: string): React.CSSProperties { const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; + const size = RISK_MARKER_SIZE[riskLevel] ?? 10; return { - width: 10, - height: 10, + width: size, + height: size, borderRadius: '50%', backgroundColor: color, boxShadow: `0 0 6px 2px ${color}88`, @@ -171,21 +185,38 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: {riskMarkers.map(({ ship, dto }) => { const level = dto.algorithms.riskScore.level; const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; + const size = RISK_MARKER_SIZE[level] ?? 12; + const halfBase = Math.round(size * 0.5); + const triHeight = Math.round(size * 0.9); return (
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} {/* 삼각형 아이콘 */}
- {/* 위험도 텍스트 */} + {/* 위험도 텍스트 (한글) */}
- {level} + {RISK_LABEL[level] ?? level}
@@ -215,6 +246,20 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: return (
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} {/* 보라 점선 원 */}
- {/* gap 라벨 */} + {/* gap 라벨: "AIS 소실 N분" */}
- {gapMin > 0 ? `${Math.round(gapMin)}분` : 'DARK'} + {gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'}
@@ -241,24 +286,43 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: })} {/* GPS 스푸핑 배지 */} - {spoofingMarkers.map(({ ship }) => ( - -
- GPS -
-
- ))} + {spoofingMarkers.map(({ ship, dto }) => { + const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100); + return ( + +
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} + {/* 스푸핑 배지 */} +
+ {`GPS ${pct}%`} +
+
+
+ ); + })} {/* 선단 leader 별 아이콘 */} {leaderShips.map(({ ship }) => ( diff --git a/frontend/src/components/korea/FishingZoneLayer.tsx b/frontend/src/components/korea/FishingZoneLayer.tsx new file mode 100644 index 0000000..0d521f1 --- /dev/null +++ b/frontend/src/components/korea/FishingZoneLayer.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { Source, Layer, Marker } from 'react-map-gl/maplibre'; +import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; + +const ZONE_FILL: Record = { + 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 = { + 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)', +}; + +/** 폴리곤 중심점 (좌표 평균) */ +function centroid(coordinates: number[][][][]): [number, number] { + let sLng = 0, sLat = 0, n = 0; + for (const poly of coordinates) { + for (const ring of poly) { + for (const [lng, lat] of ring) { + sLng += lng; sLat += lat; n++; + } + } + } + return n > 0 ? [sLng / n, sLat / n] : [0, 0]; +} + +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() { + const labels = useMemo(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fishingZonesData.features.map((f: any) => { + const [lng, lat] = centroid(f.geometry.coordinates); + return { id: f.properties.id as string, name: f.properties.name as string, lng, lat }; + }), []); + + return ( + <> + + + + + + {labels.map(({ id, name, lng, lat }) => ( + +
+ {name} +
+
+ ))} + + ); +} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index cea50a3..f0eb4b4 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -22,6 +22,7 @@ import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { AnalysisOverlay } from './AnalysisOverlay'; +import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { fetchKoreaInfra } from '../../services/infra'; import type { PowerFacility } from '../../services/infra'; @@ -272,6 +273,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.govBuildings && } {layers.nkLaunch && } {layers.nkMissile && } + {koreaFilters.illegalFishing && } {layers.cnFishing && } {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( { - const feat = data.features[0]; - const multiCoords: number[][][][] = feat.geometry.coordinates.map( - (poly: number[][][]) => poly.map( - (ring: number[][]) => ring.map(([x, y]: number[]) => epsg3857ToWgs84(x, y)), - ), - ); - return multiPolygon(multiCoords).geometry - ? { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multiCoords } } - : multiPolygon(multiCoords) as unknown as Feature; -} - export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE'; export interface FishingZoneInfo { @@ -80,15 +55,23 @@ export interface FishingZoneInfo { allowed: string[]; } +/** 수역별 허가 업종 */ +const ZONE_ALLOWED: Record = { + ZONE_I: ['PS', 'FC'], + ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'], + ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'], + ZONE_IV: ['GN', 'PS', 'FC'], +}; + /** - * 특정어업수역 Ⅰ~Ⅳ 폴리곤 (WGS84 변환 캐시) + * 특정어업수역 Ⅰ~Ⅳ 폴리곤 (사전 변환된 WGS84 GeoJSON) */ -const ZONE_POLYGONS: { id: FishingZoneId; name: string; allowed: string[]; geojson: Feature }[] = [ - { id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) }, - { id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) }, - { id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) }, - { id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) }, -]; +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, +})); /** * 특정어업수역 폴리곤 기반 수역 분류 From 9507b0da26163cacd3ffd7d3d76e7fb36091a4c7 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 14:16:42 +0900 Subject: [PATCH 23/46] =?UTF-8?q?fix:=20=EB=B6=88=EB=B2=95=EC=96=B4?= =?UTF-8?q?=EC=84=A0=20=EC=88=98=EC=97=AD=20=EB=82=B4=20=ED=95=9C=EC=A0=95?= =?UTF-8?q?=20+=20AI=20=ED=8C=A8=EB=84=90=20=ED=95=AD=EC=83=81=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20+=20API=201=EC=8B=9C=EA=B0=84=20=EC=9C=88=EB=8F=84?= =?UTF-8?q?=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불법어선 필터: classifyFishingZone으로 수역 내 비한국 어선만 판별 - 수역 내 어선에 빨간 강조 링+선박명 마커 표시 - AI 분석 패널: 데이터 유무 무관하게 항상 표시 - Backend: analyzed_at 기준 1시간 윈도우로 확대 (10분 → 1시간) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../VesselAnalysisResultRepository.java | 5 +++- .../analysis/VesselAnalysisService.java | 9 +++--- frontend/src/components/korea/KoreaMap.tsx | 30 +++++++++++++++++-- frontend/src/hooks/useKoreaFilters.ts | 15 +++++----- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java index 17f117b..af85801 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java @@ -7,5 +7,8 @@ import java.util.List; public interface VesselAnalysisResultRepository extends JpaRepository { - List findByTimestampAfter(Instant since); + List findByAnalyzedAtAfter(Instant since); + + /** 가장 최근 analyzed_at 이후 결과 전체 (최신 분석 사이클) */ + List findByAnalyzedAtGreaterThanEqual(Instant since); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 9e78162..4cac4d1 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -14,13 +14,11 @@ import java.util.List; @RequiredArgsConstructor public class VesselAnalysisService { - private static final int RECENT_MINUTES = 10; - private final VesselAnalysisResultRepository repository; private final CacheManager cacheManager; /** - * 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + * 최근 1시간 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. */ @SuppressWarnings("unchecked") public List getLatestResults() { @@ -32,8 +30,9 @@ public class VesselAnalysisService { } } - Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES); - List results = repository.findByTimestampAfter(since) + // 최근 1시간 이내 분석 결과 (Python 5분 주기 → 최대 12사이클 포함) + Instant since = Instant.now().minus(1, ChronoUnit.HOURS); + List results = repository.findByAnalyzedAtAfter(since) .stream() .map(VesselAnalysisDto::from) .toList(); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index f0eb4b4..b0d6ffd 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -24,6 +24,8 @@ import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { AnalysisOverlay } from './AnalysisOverlay'; 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'; @@ -206,6 +208,30 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.ships && } + {/* Illegal fishing vessel markers */} + {koreaFilters.illegalFishing && 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).map(s => ( + +
+
+ {s.name || s.mmsi} +
+ + ))} {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( @@ -350,8 +376,8 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
)} - {/* AI Analysis Stats Panel */} - {vesselAnalysis && vesselAnalysis.stats.total > 0 && ( + {/* AI Analysis Stats Panel — 항상 표시 */} + {vesselAnalysis && ( { const mtCat = getMarineTrafficCategory(s.typecode, s.category); 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) { - // Python 분석: 영해/접속수역 침범 또는 위험도 HIGH+ 어선 const zone = analysis.algorithms.location.zone; - const riskLevel = analysis.algorithms.riskScore.level; - const isThreat = zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE' - || riskLevel === 'CRITICAL' || riskLevel === 'HIGH'; - if (isThreat) return true; + if (zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE') return true; } - // 비한국 어선 (기본 필터) - if (mtCat === 'fishing' && s.flag !== 'KR') return true; } if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true; From 1b2f8c65c63755e95946997456596fbd68fb1978 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 15:16:51 +0900 Subject: [PATCH 24/46] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=84=A0=EB=B0=95=20=EC=9C=84=EC=B9=98=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=E2=80=94=20allShips=20prop=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KoreaMap에 allShips(전체 라이브 선박) prop 추가 - AnalysisOverlay: allShips 기반으로 분석 대상 매칭 (필터링 무관) - 불법어선 마커: allShips에서 라이브 위치 참조 (위치 갭 해소) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 1 + frontend/src/components/korea/KoreaMap.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e73a25f..ca797f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -555,6 +555,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
; @@ -128,7 +129,7 @@ const FILTER_I18N_KEY: Record = { ferryWatch: 'filters.ferryWatchMonitor', }; -export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) { +export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -208,8 +209,8 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.ships && } - {/* Illegal fishing vessel markers */} - {koreaFilters.illegalFishing && ships.filter(s => { + {/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */} + {koreaFilters.illegalFishing && (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'; @@ -303,7 +304,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.cnFishing && } {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( Date: Fri, 20 Mar 2026 15:22:06 +0900 Subject: [PATCH 25/46] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=9D=B8=ED=84=B0=EB=9E=99=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=E2=80=94=20=EC=84=A0=EB=B0=95=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20+=20flyTo=20+=20=EA=B7=BC=EA=B1=B0=20=EC=83=81=EC=84=B8=20+?= =?UTF-8?q?=20=ED=95=AD=EC=A0=81=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위험도 버튼 클릭 → 해당 레벨 선박 목록 펼침 (최대 50척) - 선박 행 클릭 → 지도 중심이동(flyTo) + 근거 상세 펼침 - 근거: 위치/활동/다크/GPS/선단 정보 표시 - 선택 선박 항적: trail 데이터를 GeoJSON LineString으로 렌더링 - KoreaMap flyTo 기능 구현 (mapRef.flyTo) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/AnalysisStatsPanel.tsx | 214 +++++++++++++++--- frontend/src/components/korea/KoreaMap.tsx | 47 +++- 2 files changed, 232 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 016d6b1..19ae44b 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,10 +1,21 @@ import { useState, useMemo } from 'react'; +import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; interface Props { stats: AnalysisStats; lastUpdated: number; isLoading: boolean; + analysisMap: Map; + ships: Ship[]; + onShipSelect?: (mmsi: string) => void; +} + +interface VesselListItem { + mmsi: string; + name: string; + score: number; + dto: VesselAnalysisDto; } /** unix ms → HH:MM 형식 */ @@ -16,17 +27,58 @@ function formatTime(ms: number): string { return `${hh}:${mm}`; } -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { +const RISK_COLOR: Record = { + CRITICAL: '#ef4444', + HIGH: '#f97316', + MEDIUM: '#eab308', + LOW: '#22c55e', +}; + +const RISK_EMOJI: Record = { + CRITICAL: '🔴', + HIGH: '🟠', + MEDIUM: '🟡', + LOW: '🟢', +}; + +const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) { const [expanded, setExpanded] = useState(true); + const [selectedLevel, setSelectedLevel] = useState(null); + const [selectedMmsi, setSelectedMmsi] = useState(null); const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + 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 = (mmsi: string) => { + setSelectedMmsi(prev => (prev === mmsi ? null : mmsi)); + onShipSelect?.(mmsi); + }; + const panelStyle: React.CSSProperties = { position: 'absolute', top: 60, right: 10, zIndex: 10, - minWidth: 160, + minWidth: 200, + maxWidth: 280, + maxHeight: 500, backgroundColor: 'rgba(12, 24, 37, 0.92)', border: '1px solid rgba(99, 179, 237, 0.25)', borderRadius: 8, @@ -35,6 +87,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { fontSize: 11, boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto', }; const headerStyle: React.CSSProperties = { @@ -45,6 +100,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', cursor: 'default', userSelect: 'none', + flexShrink: 0, }; const toggleButtonStyle: React.CSSProperties = { @@ -59,6 +115,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { const bodyStyle: React.CSSProperties = { padding: '8px 10px', + overflowY: 'auto', + flex: 1, }; const rowStyle: React.CSSProperties = { @@ -84,19 +142,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { const riskRowStyle: React.CSSProperties = { display: 'flex', - gap: 6, + gap: 4, justifyContent: 'space-between', marginTop: 4, }; - const riskBadgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 2, - color: '#cbd5e1', - fontSize: 10, - }; - return (
{/* 헤더 */} @@ -128,6 +178,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
) : ( <> + {/* 요약 행 */}
전체 {stats.total} @@ -147,25 +198,132 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
- {/* 위험도 수치 행 */} + {/* 위험도 카운트 행 — 클릭 가능 */}
-
- 🔴 - {stats.critical} -
-
- 🟠 - {stats.high} -
-
- 🟡 - {stats.medium} -
-
- 🟢 - {stats.low} -
+ {RISK_LEVELS.map(level => { + const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']; + const isActive = selectedLevel === level; + return ( + + ); + })}
+ + {/* 선박 목록 */} + {selectedLevel !== null && vesselList.length > 0 && ( + <> +
+
+ {RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척 +
+
+ {vesselList.map(item => { + const isExpanded = selectedMmsi === item.mmsi; + const color = RISK_COLOR[selectedLevel]; + const { dto } = item; + return ( +
+ {/* 선박 행 */} +
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'; + }} + > + + {item.name} + + + {item.mmsi} + + + {Math.round(item.score * 100)} + + +
+ + {/* 근거 상세 */} + {isExpanded && ( +
+
+ 위치: {dto.algorithms.location.zone} + {' '}(기선 {dto.algorithms.location.distToBaselineNm.toFixed(1)}NM) +
+
+ 활동: {dto.algorithms.activity.state} + {' '}(UCAF {dto.algorithms.activity.ucafScore.toFixed(2)}) +
+ {dto.algorithms.darkVessel.isDark && ( +
다크: {dto.algorithms.darkVessel.gapDurationMin}분 갭
+ )} + {dto.algorithms.gpsSpoofing.spoofingScore > 0 && ( +
+ GPS: 스푸핑 {Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}% +
+ )} + {dto.algorithms.cluster.clusterSize > 1 && ( +
+ 선단: {dto.algorithms.fleetRole.role} + {' '}({dto.algorithms.cluster.clusterSize}척) +
+ )} +
+ )} +
+ ); + })} +
+ + )} + + {selectedLevel !== null && vesselList.length === 0 && ( + <> +
+
+ 해당 레벨 선박 없음 +
+ + )} )}
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index a7e9aa7..c06934a 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -133,11 +133,26 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); + const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); + const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); 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]); + + 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]); + return ( )} + {/* 선택된 분석 선박 항적 */} + {selectedAnalysisMmsi && (() => { + const ship = (allShips ?? ships).find(s => s.mmsi === selectedAnalysisMmsi); + if (!ship?.trail || ship.trail.length < 2) return null; + const trailGeoJson = { + type: 'FeatureCollection' as const, + features: [{ + type: 'Feature' as const, + properties: {}, + geometry: { + type: 'LineString' as const, + coordinates: ship.trail.map(([lat, lng]) => [lng, lat]), + }, + }], + }; + return ( + + + + ); + })()} + {/* AI Analysis Stats Panel — 항상 표시 */} {vesselAnalysis && ( )} From 48c15f9c333df83bbb0665017d870a9129b884c3 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 15:42:13 +0900 Subject: [PATCH 26/46] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=ED=95=AD?= =?UTF-8?q?=EC=A0=81=20API=20+=20=EB=B2=94=EB=A1=80=20+=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20+=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: mmsi별 최신 1건만 반환 (중복 제거) - 항적: signal-batch tracks API 호출 (6시간, 5분 캐시) - 범례: 위험도 점수 기준 상세 (위치/조업/AIS/허가, 0~100) - 선박 목록: maxHeight 300px 스크롤 가능 - 선박 클릭 → flyTo + 항적 표시 + 근거 상세 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../analysis/VesselAnalysisService.java | 18 ++++- .../components/korea/AnalysisStatsPanel.tsx | 77 +++++++++++++++++-- frontend/src/components/korea/KoreaMap.tsx | 46 ++++++----- frontend/src/services/vesselTrack.ts | 39 ++++++++++ 4 files changed, 148 insertions(+), 32 deletions(-) create mode 100644 frontend/src/services/vesselTrack.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 4cac4d1..775065e 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -8,7 +8,10 @@ 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 @@ -18,7 +21,8 @@ public class VesselAnalysisService { private final CacheManager cacheManager; /** - * 최근 1시간 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + * 최근 1시간 내 분석 결과를 반환한다. mmsi별 최신 1건만. + * Caffeine 캐시(TTL 5분) 적용. */ @SuppressWarnings("unchecked") public List getLatestResults() { @@ -30,10 +34,16 @@ public class VesselAnalysisService { } } - // 최근 1시간 이내 분석 결과 (Python 5분 주기 → 최대 12사이클 포함) Instant since = Instant.now().minus(1, ChronoUnit.HOURS); - List results = repository.findByAnalyzedAtAfter(since) - .stream() + // mmsi별 최신 analyzed_at 1건만 유지 + Map latest = new LinkedHashMap<>(); + for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { + latest.merge(r.getMmsi(), r, (old, cur) -> + cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); + } + + List results = latest.values().stream() + .sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed()) .map(VesselAnalysisDto::from) .toList(); diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 19ae44b..34e8c40 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; +import { fetchVesselTrack } from '../../services/vesselTrack'; interface Props { stats: AnalysisStats; @@ -9,6 +10,7 @@ interface Props { analysisMap: Map; ships: Ship[]; onShipSelect?: (mmsi: string) => void; + onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; } interface VesselListItem { @@ -43,10 +45,36 @@ const RISK_EMOJI: Record = { const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) { +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, onShipSelect, onTrackLoad }: Props) { const [expanded, setExpanded] = useState(true); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); + const [showLegend, setShowLegend] = useState(false); const isEmpty = useMemo(() => stats.total === 0, [stats.total]); @@ -66,9 +94,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, setSelectedMmsi(null); }; - const handleVesselClick = (mmsi: string) => { + 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 = { @@ -78,7 +108,6 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, zIndex: 10, minWidth: 200, maxWidth: 280, - maxHeight: 500, backgroundColor: 'rgba(12, 24, 37, 0.92)', border: '1px solid rgba(99, 179, 237, 0.25)', borderRadius: 8, @@ -147,6 +176,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, marginTop: 4, }; + const legendDividerStyle: React.CSSProperties = { + ...dividerStyle, + marginTop: 8, + }; + + const legendBodyStyle: React.CSSProperties = { + fontSize: 9, + color: '#475569', + lineHeight: 1.7, + whiteSpace: 'pre', + }; + return (
{/* 헤더 */} @@ -157,8 +198,16 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 로딩중... )}
-
+
{formatTime(lastUpdated)} + +
+ + {expanded && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList }) => { + const company = companies.get(id); + const companyName = company?.nameCn ?? `선단 #${id}`; + const color = clusterColor(id); + const isOpen = expandedFleet === id; + const isHovered = hoveredFleetId === id; + + const mainVessels = mmsiList.filter(mmsi => { + const dto = analysisMap.get(mmsi); + return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; + }); + const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + + return ( +
+ {/* 선단 행 */} +
setHoveredFleetId(id)} + onMouseLeave={() => setHoveredFleetId(null)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '4px 10px', + cursor: 'pointer', + backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', + borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', + transition: 'background-color 0.1s', + }} + > + {/* 펼침 토글 */} + setExpandedFleet(prev => (prev === id ? null : id))} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} + > + {isOpen ? '▾' : '▸'} + + {/* 색상 인디케이터 */} + + {/* 회사명 */} + setExpandedFleet(prev => (prev === id ? null : id))} + style={{ + flex: 1, + color: '#e2e8f0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`} + > + {companyName} + + {/* 선박 수 */} + + ({mmsiList.length}척) + + {/* zoom 버튼 */} + +
+ + {/* 선단 상세 */} + {isOpen && ( +
+ {/* 선박 목록 */} +
선박:
+ {(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => { + const ship = shipMap.get(mmsi); + const dto = analysisMap.get(mmsi); + const role = dto?.algorithms.fleetRole.role ?? 'MEMBER'; + const displayName = ship?.name || mmsi; + return ( +
+ + {displayName} + + + ({role === 'LEADER' ? 'MAIN' : 'SUB'}) + + +
+ ); + })} + + {/* 어구 목록 */} + {gearCount > 0 && ( + <> +
+ 어구: {gearCount}개 +
+ {mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => ( +
+ {gear.name || gear.mmsi} +
+ ))} + + )} +
+ )} +
+ ); + }) + )} +
+ )} +
+ + ); +} + +export default FleetClusterLayer; diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2e66ec4..fce6c06 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -22,6 +22,7 @@ import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { AnalysisOverlay } from './AnalysisOverlay'; +import { FleetClusterLayer } from './FleetClusterLayer'; import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -162,6 +163,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF 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 }, + ); + }, []); + return ( } {koreaFilters.illegalFishing && } {layers.cnFishing && } + {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( + + )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( { 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 | null = null; + +export async function fetchFleetCompanies(): Promise> { + 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; +} From 730872d47ea22f68c024af07f2bfcae16828593a Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 18:43:48 +0900 Subject: [PATCH 34/46] =?UTF-8?q?feat:=20=EB=B9=84=ED=97=88=EA=B0=80=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20+=20=ED=8F=B4=EB=A6=AC=EA=B3=A4=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnalysisStatsPanel: 어구그룹/어구수 통계 (주황색) - FleetClusterLayer: 비허가 어구 ConvexHull 폴리곤 (주황 점선) + 목록 패널 - 허가 선단(HSL 색상) vs 비허가 어구(주황) 별도 시각화 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/AnalysisStatsPanel.tsx | 32 ++- .../components/korea/FleetClusterLayer.tsx | 221 +++++++++++++++++- frontend/src/components/korea/KoreaMap.tsx | 1 + 3 files changed, 252 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index d1c86ee..63b1508 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -9,6 +9,7 @@ interface Props { isLoading: boolean; analysisMap: Map; ships: Ship[]; + allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; } @@ -70,7 +71,7 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad }: Props) { +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { const [expanded, setExpanded] = useState(true); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); @@ -78,6 +79,23 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + const gearStats = useMemo(() => { + const source = allShips ?? ships; + const gearPattern = /^(.+?)_\d+_\d+_?$/; + const parentMap = new Map(); + for (const s of source) { + 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[] = []; @@ -244,6 +262,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 선단수 {stats.clusterCount}
+ {gearStats.groups > 0 && ( + <> +
+ 어구그룹 + {gearStats.groups} +
+
+ 어구수 + {gearStats.count} +
+ + )}
diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index fbb14ed..2365f95 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -88,6 +88,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, const [expanded, setExpanded] = useState(true); const [expandedFleet, setExpandedFleet] = useState(null); const [hoveredFleetId, setHoveredFleetId] = useState(null); + const [expandedGearGroup, setExpandedGearGroup] = useState(null); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); @@ -123,6 +124,71 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, return m; }, [ships]); + // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } + const gearGroupMap = useMemo(() => { + const gearPattern = /^(.+?)_\d+_\d+_?$/; + const nameToShip = new Map(); + for (const s of ships) { + const nm = (s.name || '').trim(); + if (nm && !gearPattern.test(nm)) { + nameToShip.set(nm, s); + } + } + const map = new Map(); + for (const s of ships) { + const m = (s.name || '').match(gearPattern); + if (!m) continue; + const parentName = m[1].trim(); + const entry = map.get(parentName) ?? { parent: nameToShip.get(parentName) ?? null, gears: [] }; + entry.gears.push(s); + map.set(parentName, entry); + } + return map; + }, [ships]); + + // 비허가 어구 클러스터 GeoJSON + const gearClusterGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + for (const [parentName, { parent, gears }] of gearGroupMap) { + const points: [number, number][] = gears.map(g => [g.lng, g.lat]); + if (parent) points.push([parent.lng, parent.lat]); + if (points.length < 3) continue; + const hull = convexHull(points); + const padded = padPolygon(hull, 0.01); + padded.push(padded[0]); + features.push({ + type: 'Feature', + properties: { name: parentName, gearCount: gears.length }, + geometry: { type: 'Polygon', coordinates: [padded] }, + }); + } + return { type: 'FeatureCollection', features }; + }, [gearGroupMap]); + + // 어구 그룹 목록 (어구 수 내림차순) + const gearGroupList = useMemo(() => { + return Array.from(gearGroupMap.entries()) + .map(([name, { parent, gears }]) => ({ name, parent, gears })) + .sort((a, b) => b.gears.length - a.gears.length); + }, [gearGroupMap]); + + const handleGearGroupZoom = useCallback((parentName: string) => { + const entry = gearGroupMap.get(parentName); + if (!entry) return; + const all: Ship[] = [...entry.gears]; + if (entry.parent) all.push(entry.parent); + if (all.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const s of all) { + if (s.lat < minLat) minLat = s.lat; + if (s.lat > maxLat) maxLat = s.lat; + if (s.lng < minLng) minLng = s.lng; + if (s.lng > maxLng) maxLng = s.lng; + } + if (minLat === Infinity) return; + onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }, [gearGroupMap, onFleetZoom]); + // GeoJSON 피처 생성 const polygonFeatures = useMemo((): ClusterFeature[] => { const features: ClusterFeature[] = []; @@ -291,6 +357,27 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> + {/* 비허가 어구 클러스터 폴리곤 */} + + + + + {/* 선단 목록 패널 */}
@@ -307,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
{expanded && ( -
+
{fleetList.length === 0 ? (
선단 데이터 없음 @@ -466,6 +553,138 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, ); }) )} + + {/* 비허가 어구 그룹 섹션 */} + {gearGroupList.length > 0 && ( + <> +
+
+ 비허가 어구 그룹 ({gearGroupList.length}개) +
+ {gearGroupList.map(({ name, parent, gears }) => { + const isOpen = expandedGearGroup === name; + return ( +
+
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} + > + setExpandedGearGroup(prev => (prev === name ? null : name))} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} + > + {isOpen ? '▾' : '▸'} + + + setExpandedGearGroup(prev => (prev === name ? null : name))} + style={{ + flex: 1, + color: '#e2e8f0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + title={name} + > + {name} + + + ({gears.length}개) + + +
+ + {isOpen && ( +
+ {parent && ( +
+ 모선: {parent.name || parent.mmsi} +
+ )} +
어구 목록:
+ {gears.map(g => ( +
+ + {g.name || g.mmsi} + + +
+ ))} +
+ )} +
+ ); + })} + + )}
)}
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index fce6c06..d9283c4 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -451,6 +451,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF isLoading={vesselAnalysis.isLoading} analysisMap={vesselAnalysis.analysisMap} ships={allShips ?? ships} + allShips={allShips ?? ships} onShipSelect={handleAnalysisShipSelect} onTrackLoad={handleTrackLoad} /> From befcd122778a81359bc11d9673da401402d3619f Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 18:50:12 +0900 Subject: [PATCH 35/46] =?UTF-8?q?fix:=20=EB=B9=84=ED=97=88=EA=B0=80=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=20=EA=B7=B8=EB=A3=B9=ED=95=91=EC=97=90=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=EC=A0=9C=ED=95=9C(10NM)=20+=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=EC=8B=9C=EA=B0=81(60=EB=B6=84)=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/AnalysisStatsPanel.tsx | 3 +++ .../components/korea/FleetClusterLayer.tsx | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 63b1508..dd8b27f 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -82,8 +82,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 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(); 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(); diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 2365f95..57fa7c2 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -127,6 +127,10 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } const gearGroupMap = useMemo(() => { const gearPattern = /^(.+?)_\d+_\d+_?$/; + const MAX_DIST_DEG = 0.15; // ~10NM — 모선과 어구 간 최대 거리 + const STALE_MS = 60 * 60_000; // 60분 이내 수신 신호만 + const now = Date.now(); + const nameToShip = new Map(); for (const s of ships) { const nm = (s.name || '').trim(); @@ -134,12 +138,25 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, nameToShip.set(nm, s); } } + const map = new Map(); for (const s of ships) { + // 60분 이내 수신 신호만 + if (now - s.lastSeen > STALE_MS) continue; + const m = (s.name || '').match(gearPattern); if (!m) continue; const parentName = m[1].trim(); - const entry = map.get(parentName) ?? { parent: nameToShip.get(parentName) ?? null, gears: [] }; + const parent = nameToShip.get(parentName) ?? null; + + // 모선이 있으면 거리 제한 적용 + if (parent) { + const dlat = Math.abs(s.lat - parent.lat); + const dlng = Math.abs(s.lng - parent.lng); + if (dlat > MAX_DIST_DEG || dlng > MAX_DIST_DEG) continue; + } + + const entry = map.get(parentName) ?? { parent, gears: [] }; entry.gears.push(s); map.set(parentName, entry); } From a5dc5bbf35f4a37957db2efe3f02d34fb1b3143d Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 18:53:45 +0900 Subject: [PATCH 36/46] =?UTF-8?q?fix:=20=EB=B9=84=ED=97=88=EA=B0=80=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=20=EB=8F=85=EB=A6=BD=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EA=B1=B0=EB=A6=AC=EC=A0=9C=ED=95=9C(10NM)?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20=EB=8F=99=EB=AA=85=20?= =?UTF-8?q?=EC=9B=90=EA=B1=B0=EB=A6=AC=20=EC=96=B4=EA=B5=AC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 57fa7c2..920bc45 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -127,8 +127,8 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } const gearGroupMap = useMemo(() => { const gearPattern = /^(.+?)_\d+_\d+_?$/; - const MAX_DIST_DEG = 0.15; // ~10NM — 모선과 어구 간 최대 거리 - const STALE_MS = 60 * 60_000; // 60분 이내 수신 신호만 + const MAX_DIST_DEG = 0.15; // ~10NM + const STALE_MS = 60 * 60_000; const now = Date.now(); const nameToShip = new Map(); @@ -139,26 +139,35 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, } } - const map = new Map(); + // 1단계: 같은 모선명 어구 수집 (60분 이내만) + const rawGroups = new Map(); for (const s of ships) { - // 60분 이내 수신 신호만 if (now - s.lastSeen > STALE_MS) continue; - const m = (s.name || '').match(gearPattern); if (!m) continue; const parentName = m[1].trim(); + const arr = rawGroups.get(parentName) ?? []; + arr.push(s); + rawGroups.set(parentName, arr); + } + + // 2단계: 거리 기반 서브 클러스터링 (같은 이름이라도 멀면 분리) + const map = new Map(); + for (const [parentName, gears] of rawGroups) { const parent = nameToShip.get(parentName) ?? null; - // 모선이 있으면 거리 제한 적용 - if (parent) { - const dlat = Math.abs(s.lat - parent.lat); - const dlng = Math.abs(s.lng - parent.lng); - if (dlat > MAX_DIST_DEG || dlng > MAX_DIST_DEG) continue; - } + // 기준점: 모선 있으면 모선 위치, 없으면 첫 어구 + const anchor = parent ?? gears[0]; + if (!anchor) continue; - const entry = map.get(parentName) ?? { parent, gears: [] }; - entry.gears.push(s); - map.set(parentName, entry); + const nearby = gears.filter(g => { + const dlat = Math.abs(g.lat - anchor.lat); + const dlng = Math.abs(g.lng - anchor.lng); + return dlat <= MAX_DIST_DEG && dlng <= MAX_DIST_DEG; + }); + + if (nearby.length === 0) continue; + map.set(parentName, { parent, gears: nearby }); } return map; }, [ships]); From 8c008c69ec98186b2d413925c4c704a835024c92 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 19:07:46 +0900 Subject: [PATCH 37/46] =?UTF-8?q?feat:=20=EC=84=A0=ED=83=9D=20=EC=96=B4?= =?UTF-8?q?=EA=B5=AC=EA=B7=B8=EB=A3=B9=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=8F=B4=EB=A6=AC=EA=B3=A4=20+=20?= =?UTF-8?q?=EB=AA=A8=EC=84=A0=20=EA=B0=95=EC=A1=B0=20=EB=A7=88=EC=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택된 어구그룹: 진한 주황 fill(0.25) + 굵은 경계선(3px) - 모선 존재 시: 28px 주황 원 + glow + 'M' 라벨 + 선박명 - zoom 시 자동 선택 + 펼침 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 920bc45..9163531 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Source, Layer } from 'react-map-gl/maplibre'; +import { Source, Layer, Marker } from 'react-map-gl/maplibre'; import type { GeoJSON } from 'geojson'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; @@ -89,6 +89,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, const [expandedFleet, setExpandedFleet] = useState(null); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); + const [selectedGearGroup, setSelectedGearGroup] = useState(null); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); @@ -199,6 +200,8 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, }, [gearGroupMap]); const handleGearGroupZoom = useCallback((parentName: string) => { + setSelectedGearGroup(prev => prev === parentName ? null : parentName); + setExpandedGearGroup(parentName); const entry = gearGroupMap.get(parentName); if (!entry) return; const all: Ship[] = [...entry.gears]; @@ -383,6 +386,60 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> + {/* 선택된 어구 그룹 하이라이트 + 모선 마커 */} + {selectedGearGroup && (() => { + const entry = gearGroupMap.get(selectedGearGroup); + if (!entry) return null; + const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]); + if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]); + + const hlFeatures: GeoJSON.Feature[] = []; + if (points.length >= 3) { + const hull = convexHull(points); + const padded = padPolygon(hull, 0.01); + padded.push(padded[0]); + hlFeatures.push({ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [padded] }, + }); + } + const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures }; + + return ( + <> + {hlFeatures.length > 0 && ( + + + + + )} + {entry.parent && ( + +
+ M +
+
+ {entry.parent.name || entry.parent.mmsi} +
+
+ )} + + ); + })()} + {/* 비허가 어구 클러스터 폴리곤 */} Date: Fri, 20 Mar 2026 21:11:56 +0900 Subject: [PATCH 38/46] =?UTF-8?q?refactor:=20deck.gl=20=EC=A0=84=EB=A9=B4?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=E2=80=94=20DOM=20Marker=20=E2=86=92=20?= =?UTF-8?q?GPU=20=EB=A0=8C=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deck.gl 9.2 설치 + DeckGLOverlay(MapboxOverlay interleaved) 통합 - 정적 마커 11종 → useStaticDeckLayers (IconLayer/TextLayer, SVG DataURI) - 분석 오버레이 → useAnalysisDeckLayers (ScatterplotLayer/TextLayer) - 불법어선/어구/수역 라벨 → deck.gl ScatterplotLayer/TextLayer - 줌 레벨별 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x) - NK 미사일 궤적 PathLayer 추가 + 정적 마커 클릭 Popup - 해저케이블 날짜변경선(180도) 좌표 보정 - 기존 DOM Marker 제거로 렌더링 성능 대폭 개선 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package-lock.json | 278 ++++++ frontend/package.json | 3 + .../src/components/korea/AnalysisOverlay.tsx | 221 +---- .../korea/ChineseFishingOverlay.tsx | 186 +--- .../src/components/korea/CoastGuardLayer.tsx | 182 +--- .../src/components/korea/FishingZoneLayer.tsx | 72 +- .../components/korea/FleetClusterLayer.tsx | 62 +- .../src/components/korea/GovBuildingLayer.tsx | 117 +-- .../components/korea/KoreaAirportLayer.tsx | 150 ++- frontend/src/components/korea/KoreaMap.tsx | 304 ++++-- .../components/korea/MilitaryBaseLayer.tsx | 128 +-- .../src/components/korea/NKLaunchLayer.tsx | 130 +-- .../components/korea/NKMissileEventLayer.tsx | 69 +- .../src/components/korea/NavWarningLayer.tsx | 167 ++-- frontend/src/components/korea/PiracyLayer.tsx | 130 +-- frontend/src/components/korea/PortLayer.tsx | 126 +-- .../components/korea/SubmarineCableLayer.tsx | 22 +- .../src/components/korea/WindFarmLayer.tsx | 127 +-- .../src/components/layers/DeckGLOverlay.tsx | 19 + frontend/src/hooks/useAnalysisDeckLayers.ts | 187 ++++ frontend/src/hooks/useStaticDeckLayers.ts | 893 ++++++++++++++++++ frontend/src/utils/svgToDataUri.ts | 3 + 22 files changed, 2147 insertions(+), 1429 deletions(-) create mode 100644 frontend/src/components/layers/DeckGLOverlay.tsx create mode 100644 frontend/src/hooks/useAnalysisDeckLayers.ts create mode 100644 frontend/src/hooks/useStaticDeckLayers.ts create mode 100644 frontend/src/utils/svgToDataUri.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 161ee08..2c88106 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "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", @@ -293,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", @@ -921,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": { @@ -1004,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", @@ -1784,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, @@ -3493,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, @@ -4328,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, diff --git a/frontend/package.json b/frontend/package.json index 175e19f..10d7a99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "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", diff --git a/frontend/src/components/korea/AnalysisOverlay.tsx b/frontend/src/components/korea/AnalysisOverlay.tsx index bf9a8c1..1cc900d 100644 --- a/frontend/src/components/korea/AnalysisOverlay.tsx +++ b/frontend/src/components/korea/AnalysisOverlay.tsx @@ -1,33 +1,7 @@ import { useMemo } from 'react'; import { Marker } from 'react-map-gl/maplibre'; -import type { Ship, VesselAnalysisDto } from '../../types'; +import type { VesselAnalysisDto, Ship } from '../../types'; -const RISK_COLORS: Record = { - CRITICAL: '#ef4444', - HIGH: '#f97316', - MEDIUM: '#eab308', - LOW: '#22c55e', -}; - -const RISK_LABEL: Record = { - CRITICAL: '긴급', - HIGH: '경고', - MEDIUM: '주의', - LOW: '정상', -}; - -const RISK_MARKER_SIZE: Record = { - CRITICAL: 18, - HIGH: 14, - MEDIUM: 12, -}; - -const RISK_PRIORITY: Record = { - CRITICAL: 0, - HIGH: 1, - MEDIUM: 2, - LOW: 3, -}; interface Props { ships: Ship[]; @@ -41,58 +15,18 @@ interface AnalyzedShip { dto: VesselAnalysisDto; } -/** 위험도 펄스 애니메이션 인라인 스타일 */ -function riskPulseStyle(riskLevel: string): React.CSSProperties { - const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; - const size = RISK_MARKER_SIZE[riskLevel] ?? 10; - return { - width: size, - height: size, - borderRadius: '50%', - backgroundColor: color, - boxShadow: `0 0 6px 2px ${color}88`, - animation: riskLevel === 'CRITICAL' ? 'pulse 1s infinite' : undefined, - pointerEvents: 'none', - }; -} - +/** + * 위험도/다크베셀/스푸핑 마커는 useAnalysisDeckLayers + DeckGLOverlay로 GPU 렌더링. + * 이 컴포넌트는 DOM Marker가 필요한 leader 별 아이콘만 담당. + */ export function AnalysisOverlay({ ships, analysisMap, clusters: _clusters, activeFilter }: Props) { - // analysisMap에 있는 선박만 대상 const analyzedShips: AnalyzedShip[] = useMemo(() => { return ships .filter(s => analysisMap.has(s.mmsi)) .map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! })); }, [ships, analysisMap]); - // 위험도 마커 — CRITICAL/HIGH 우선 최대 100개 - const riskMarkers = useMemo(() => { - return 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); - }, [analyzedShips]); - - // 다크베셀 마커 - const darkVesselMarkers = useMemo(() => { - if (activeFilter !== 'darkVessel') return []; - return analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark); - }, [analyzedShips, activeFilter]); - - // GPS 스푸핑 마커 - const spoofingMarkers = useMemo(() => { - return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5); - }, [analyzedShips]); - - // 선단 연결선은 ShipLayer에서 선박 클릭 시 Python cluster_id 기반으로 표시 - - // leader 선박 목록 (cnFishing 필터 ON) + // 선단 leader 별 아이콘 (cnFishing 필터 ON) const leaderShips = useMemo(() => { if (activeFilter !== 'cnFishing') return []; return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader); @@ -100,149 +34,6 @@ export function AnalysisOverlay({ ships, analysisMap, clusters: _clusters, activ return ( <> - {/* 위험도 마커 */} - {riskMarkers.map(({ ship, dto }) => { - const level = dto.algorithms.riskScore.level; - const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; - const size = RISK_MARKER_SIZE[level] ?? 12; - const halfBase = Math.round(size * 0.5); - const triHeight = Math.round(size * 0.9); - return ( - -
- {/* 선박명 */} - {ship.name && ( -
- {ship.name} -
- )} - {/* 삼각형 아이콘 */} -
- {/* 위험도 텍스트 (한글) */} -
- {RISK_LABEL[level] ?? level} -
-
- - ); - })} - - {/* CRITICAL 펄스 오버레이 */} - {riskMarkers - .filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL') - .map(({ ship }) => ( - -
- - ))} - - {/* 다크베셀 마커 */} - {darkVesselMarkers.map(({ ship, dto }) => { - const gapMin = dto.algorithms.darkVessel.gapDurationMin; - return ( - -
- {/* 선박명 */} - {ship.name && ( -
- {ship.name} -
- )} - {/* 보라 점선 원 */} -
- {/* gap 라벨: "AIS 소실 N분" */} -
- {gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'} -
-
- - ); - })} - - {/* GPS 스푸핑 배지 */} - {spoofingMarkers.map(({ ship, dto }) => { - const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100); - return ( - -
- {/* 선박명 */} - {ship.name && ( -
- {ship.name} -
- )} - {/* 스푸핑 배지 */} -
- {`GPS ${pct}%`} -
-
-
- ); - })} - {/* 선단 leader 별 아이콘 */} {leaderShips.map(({ ship }) => ( diff --git a/frontend/src/components/korea/ChineseFishingOverlay.tsx b/frontend/src/components/korea/ChineseFishingOverlay.tsx index 7579f8e..fc935ec 100644 --- a/frontend/src/components/korea/ChineseFishingOverlay.tsx +++ b/frontend/src/components/korea/ChineseFishingOverlay.tsx @@ -1,57 +1,6 @@ import { useMemo } from 'react'; -import { Marker, Source, Layer } from 'react-map-gl/maplibre'; +import { Source, Layer } from 'react-map-gl/maplibre'; import type { Ship, VesselAnalysisDto } 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 ; - case 'gillnet': - return ; - case 'stow_net': - return ; - case 'purse_seine': - return ; - default: - return ; - } -} - -/** 선박 역할 추정 — 속도/크기/카테고리 기반 */ -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' }; -} /** * 어구/어망 이름에서 모선명 추출 @@ -82,54 +31,7 @@ interface Props { analysisMap?: Map; } -export function ChineseFishingOverlay({ ships, analysisMap }: 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]); - - // Python fleet_role → 표시용 role 매핑 - const resolveRole = (s: Ship): { role: string; roleKo: string; color: string } => { - const dto = analysisMap?.get(s.mmsi); - if (dto) { - const fleetRole = dto.algorithms.fleetRole.role; - const riskLevel = dto.algorithms.riskScore.level; - if (fleetRole === 'LEADER') { - return { role: 'PT', roleKo: '본선', color: riskLevel === 'CRITICAL' ? '#ef4444' : '#f97316' }; - } - if (fleetRole === 'MEMBER') { - return { role: 'PT-S', roleKo: '부속', color: '#fb923c' }; - } - } - return estimateRole(s); - }; - - // 조업 분석 결과 - const analyzed = useMemo(() => { - return chineseFishing.map(s => ({ - ship: s, - analysis: analyzeFishing(s), - role: resolveRole(s), - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chineseFishing, analysisMap]); - - // 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척) - // Python activity_state === 'FISHING'인 선박도 조업 중으로 간주 - const operating = useMemo(() => { - return analyzed - .filter(a => { - if (a.analysis.isOperating) return true; - const dto = analysisMap?.get(a.ship.mmsi); - return dto?.algorithms.activity.state === 'FISHING'; - }) - .slice(0, 100); - }, [analyzed, analysisMap]); - +export function ChineseFishingOverlay({ ships, analysisMap: _analysisMap }: Props) { // 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선) const gearLinks: GearToParentLink[] = useMemo(() => { const gearPattern = /^.+_\d+_\d*$|%$/; @@ -185,21 +87,6 @@ export function ChineseFishingOverlay({ ships, analysisMap }: 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 ( <> {/* 어구/어망 → 모선 연결선 */} @@ -217,75 +104,6 @@ export function ChineseFishingOverlay({ ships, analysisMap }: Props) { /> )} - - {/* 어구/어망 위치 마커 (모선 연결된 것) */} - {gearLinks.map(link => ( - -
- -
-
- ← {link.parentName} -
-
- ))} - - {/* 조업 중 어선 — 어구 아이콘 */} - {operating.map(({ ship, analysis }) => { - const meta = GEAR_LABELS[analysis.gearType]; - return ( - -
- -
-
- ); - })} - - {/* 본선/부속선/어선 역할 라벨 (본선/부속/운반만, 최대 100개) */} - {analyzed.filter(a => a.role.role && a.role.role !== 'FV').slice(0, 100).map(({ ship, role }) => ( - -
- {role.roleKo} -
-
- ))} - - {/* 운반선 라벨 */} - {carriers.map(s => ( - -
- 운반 -
-
- ))} ); } diff --git a/frontend/src/components/korea/CoastGuardLayer.tsx b/frontend/src/components/korea/CoastGuardLayer.tsx index 623579c..4e1651f 100644 --- a/frontend/src/components/korea/CoastGuardLayer.tsx +++ b/frontend/src/components/korea/CoastGuardLayer.tsx @@ -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 = { @@ -13,143 +12,52 @@ const TYPE_COLOR: Record = { navy: '#3b82f6', }; -const TYPE_SIZE: Record = { - 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 ( - - - - - - - ); - } - - if (isVts) { - return ( - - - - - - - - ); - } - - return ( - - - - - - - {(type === 'hq' || type === 'regional') && ( - - )} - - ); +interface Props { + selected: CoastGuardFacility | null; + onClose: () => void; } -export function CoastGuardLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(f); }}> -
- - {(f.type === 'hq' || f.type === 'regional') && ( -
- {f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'} -
- )} - {f.type === 'navy' && ( -
- {f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)} -
- )} - {f.type === 'vts' && ( -
- VTS -
- )} -
-
- ); - })} - - {selected && ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
- {selected.type === 'navy' ? ( - - ) : selected.type === 'vts' ? ( - 📡 - ) : ( - 🚔 - )} - {selected.name} -
-
- - {CG_TYPE_LABEL[selected.type]} - - - {t('coastGuard.agency')} - -
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
-
- )} - + +
+
+ {selected.type === 'navy' ? ( + + ) : selected.type === 'vts' ? ( + 📡 + ) : ( + 🚔 + )} + {selected.name} +
+
+ + {CG_TYPE_LABEL[selected.type]} + + + {t('coastGuard.agency')} + +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
); } diff --git a/frontend/src/components/korea/FishingZoneLayer.tsx b/frontend/src/components/korea/FishingZoneLayer.tsx index 0d521f1..a7e006d 100644 --- a/frontend/src/components/korea/FishingZoneLayer.tsx +++ b/frontend/src/components/korea/FishingZoneLayer.tsx @@ -1,5 +1,4 @@ -import { useMemo } from 'react'; -import { Source, Layer, Marker } from 'react-map-gl/maplibre'; +import { Source, Layer } from 'react-map-gl/maplibre'; import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; const ZONE_FILL: Record = { @@ -16,18 +15,6 @@ const ZONE_LINE: Record = { ZONE_IV: 'rgba(239, 68, 68, 0.6)', }; -/** 폴리곤 중심점 (좌표 평균) */ -function centroid(coordinates: number[][][][]): [number, number] { - let sLng = 0, sLat = 0, n = 0; - for (const poly of coordinates) { - for (const ring of poly) { - for (const [lng, lat] of ring) { - sLng += lng; sLat += lat; n++; - } - } - } - return n > 0 ? [sLng / n, sLat / n] : [0, 0]; -} const fillColor = [ 'match', ['get', 'id'], @@ -48,46 +35,23 @@ const lineColor = [ ] as maplibregl.ExpressionSpecification; export function FishingZoneLayer() { - const labels = useMemo(() => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fishingZonesData.features.map((f: any) => { - const [lng, lat] = centroid(f.geometry.coordinates); - return { id: f.properties.id as string, name: f.properties.name as string, lng, lat }; - }), []); - return ( - <> - - - - - - {labels.map(({ id, name, lng, lat }) => ( - -
- {name} -
-
- ))} - + + + + ); } diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 9163531..79f93fe 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,16 +1,23 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Source, Layer, Marker } from 'react-map-gl/maplibre'; +import { Source, Layer } from 'react-map-gl/maplibre'; import type { GeoJSON } from 'geojson'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; +export interface SelectedGearGroupData { + parent: Ship | null; + gears: Ship[]; + groupName: string; +} + interface Props { ships: Ship[]; analysisMap: Map; clusters: Map; onShipSelect?: (mmsi: string) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; + onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; } // 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 @@ -83,7 +90,7 @@ interface ClusterLineFeature { type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; -export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom }: Props) { +export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange }: Props) { const [companies, setCompanies] = useState>(new Map()); const [expanded, setExpanded] = useState(true); const [expandedFleet, setExpandedFleet] = useState(null); @@ -173,6 +180,20 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, return map; }, [ships]); + // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) + useEffect(() => { + if (!selectedGearGroup) { + onSelectedGearChange?.(null); + return; + } + const entry = gearGroupMap.get(selectedGearGroup); + if (entry) { + onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup }); + } else { + onSelectedGearChange?.(null); + } + }, [selectedGearGroup, gearGroupMap, onSelectedGearChange]); + // 비허가 어구 클러스터 GeoJSON const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; @@ -386,7 +407,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> - {/* 선택된 어구 그룹 하이라이트 + 모선 마커 */} + {/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */} {selectedGearGroup && (() => { const entry = gearGroupMap.get(selectedGearGroup); if (!entry) return null; @@ -404,39 +425,14 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, geometry: { type: 'Polygon', coordinates: [padded] }, }); } + if (hlFeatures.length === 0) return null; const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures }; return ( - <> - {hlFeatures.length > 0 && ( - - - - - )} - {entry.parent && ( - -
- M -
-
- {entry.parent.name || entry.parent.mmsi} -
-
- )} - + + + + ); })()} diff --git a/frontend/src/components/korea/GovBuildingLayer.tsx b/frontend/src/components/korea/GovBuildingLayer.tsx index 0ce0401..ddb609b 100644 --- a/frontend/src/components/korea/GovBuildingLayer.tsx +++ b/frontend/src/components/korea/GovBuildingLayer.tsx @@ -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 = { @@ -18,79 +16,48 @@ const TYPE_STYLE: Record defense: { icon: '🛡️', label: '국방부', color: '#dc2626' }, }; -export function GovBuildingLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(g); }}> -
-
- {ts.icon} -
-
- {g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo} -
-
-
- ); - })} - - {selected && (() => { - const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN; - const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive; - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="320px" className="gl-popup"> -
-
- {cs.flag} - {ts.icon} {selected.nameKo} -
-
- - {ts.label} - - - {cs.label} - -
-
- {selected.description} -
-
- {selected.name} -
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
-
- ); - })()} - + +
+
+ {cs.flag} + {ts.icon} {selected.nameKo} +
+
+ + {ts.label} + + + {cs.label} + +
+
+ {selected.description} +
+
+ {selected.name} +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
); } diff --git a/frontend/src/components/korea/KoreaAirportLayer.tsx b/frontend/src/components/korea/KoreaAirportLayer.tsx index 8febd1d..7a37366 100644 --- a/frontend/src/components/korea/KoreaAirportLayer.tsx +++ b/frontend/src/components/korea/KoreaAirportLayer.tsx @@ -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 = { @@ -12,100 +10,72 @@ const COUNTRY_COLOR: Record(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 ( - { e.originalEvent.stopPropagation(); setSelected(ap); }}> -
- - - - -
- {ap.nameKo.replace('국제공항', '').replace('공항', '')} -
-
-
- ); - })} - - {selected && (() => { - const color = getColor(selected); - const info = getCountryInfo(selected); - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
- {info.flag} - {selected.nameKo} -
-
- {selected.intl && ( - - {t('airport.international')} - - )} - {selected.domestic && ( - - {t('airport.domestic')} - - )} - - {info.label} - -
-
-
IATA : {selected.id}
-
ICAO : {selected.icao}
-
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
- -
-
- ); - })()} - + +
+
+ {info.flag} + {selected.nameKo} +
+
+ {selected.intl && ( + + {t('airport.international')} + + )} + {selected.domestic && ( + + {t('airport.domestic')} + + )} + + {info.label} + +
+
+
IATA : {selected.id}
+
ICAO : {selected.icao}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+ +
+
); } diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index d9283c4..d459fc5 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -1,28 +1,28 @@ -import { useRef, useState, useEffect, useCallback } 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 { AnalysisOverlay } from './AnalysisOverlay'; import { FleetClusterLayer } from './FleetClusterLayer'; +import type { SelectedGearGroupData } from './FleetClusterLayer'; import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -137,6 +137,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); + const [selectedGearData, setSelectedGearData] = useState(null); + const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); + const [staticPickInfo, setStaticPickInfo] = useState(null); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); @@ -170,12 +173,206 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ); }, []); + // 줌 레벨별 아이콘/심볼 스케일 배율 + const zoomScale = useMemo(() => { + if (zoomLevel <= 6) return 0.6; + if (zoomLevel <= 9) return 1.0; + if (zoomLevel <= 12) return 1.4; + return 1.8; + }, [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, + 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 analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' + : koreaFilters.darkVessel ? 'darkVessel' + : layers.cnFishing ? 'cnFishing' + : null; + + const analysisDeckLayers = useAnalysisDeckLayers( + vesselAnalysis?.analysisMap ?? new Map(), + allShips ?? ships, + analysisActiveFilter, + zoomScale, + ); + return ( setZoomLevel(Math.floor(e.viewState.zoom))} > @@ -241,34 +438,6 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.ships && } - {/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */} - {koreaFilters.illegalFishing && (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).map(s => ( - -
- {/* 강조 펄스 링 — 선박 아이콘 중앙에 오버레이 */} -
- {/* 선박명 — 아이콘 아래 */} -
- {s.name || s.mmsi} -
-
- - ))} {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( @@ -330,12 +499,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.aircraft && aircraft.length > 0 && } {layers.cables && } {layers.cctv && } - {layers.windFarm && } - {layers.ports && } - {layers.militaryBases && } - {layers.govBuildings && } - {layers.nkLaunch && } - {layers.nkMissile && } + {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} {koreaFilters.illegalFishing && } {layers.cnFishing && } {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( @@ -345,6 +509,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF clusters={vesselAnalysis.clusters} onShipSelect={handleAnalysisShipSelect} onFleetZoom={handleFleetZoom} + onSelectedGearChange={setSelectedGearData} /> )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( @@ -352,20 +517,47 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ships={allShips ?? ships} analysisMap={vesselAnalysis.analysisMap} clusters={vesselAnalysis.clusters} - activeFilter={ - koreaFilters.illegalFishing ? 'illegalFishing' - : koreaFilters.darkVessel ? 'darkVessel' - : layers.cnFishing ? 'cnFishing' - : null - } + activeFilter={analysisActiveFilter} /> )} - {layers.airports && } - {layers.coastGuard && } - {layers.navWarning && } + + {/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */} + + {/* 정적 마커 클릭 Popup */} + {staticPickInfo && (() => { + const obj = staticPickInfo.object; + const lat = obj.lat ?? obj.launchLat ?? 0; + const lng = obj.lng ?? obj.launchLng ?? 0; + if (!lat || !lng) return null; + return ( + setStaticPickInfo(null)} + closeOnClick={false} + style={{ maxWidth: 280 }} + > +
+
+ {obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind} +
+ {obj.description &&
{obj.description}
} + {obj.date &&
날짜: {obj.date} {obj.time || ''}
} + {obj.missileType &&
미사일: {obj.missileType}
} + {obj.range &&
사거리: {obj.range}
} + {obj.operator &&
운영: {obj.operator}
} + {obj.capacity &&
용량: {obj.capacity}
} +
+
+ ); + })()} {layers.osint && } {layers.eez && } - {layers.piracy && } {/* Filter Status Banner */} {(() => { diff --git a/frontend/src/components/korea/MilitaryBaseLayer.tsx b/frontend/src/components/korea/MilitaryBaseLayer.tsx index f33cd5e..c413609 100644 --- a/frontend/src/components/korea/MilitaryBaseLayer.tsx +++ b/frontend/src/components/korea/MilitaryBaseLayer.tsx @@ -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 = { @@ -18,91 +16,49 @@ const TYPE_STYLE: Record joint: { icon: '⭐', label: '합동기지', color: '#a78bfa' }, }; -function _MilIcon({ type, size = 16 }: { type: string; size?: number }) { - const ts = TYPE_STYLE[type] || TYPE_STYLE.army; - return ( - - - {ts.icon} - - ); +interface Props { + selected: MilitaryBase | null; + onClose: () => void; } -export function MilitaryBaseLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(base); }}> -
-
- {ts.icon} -
-
- {base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo} -
-
-
- ); - })} - - {selected && (() => { - const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN; - const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army; - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="300px" className="gl-popup"> -
-
- {cs.flag} - {ts.icon} {selected.nameKo} -
-
- - {ts.label} - - - {cs.label} - -
-
- {selected.description} -
-
-
시설명 : {selected.name}
-
유형 : {ts.label}
-
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
-
- ); - })()} - + +
+
+ {cs.flag} + {ts.icon} {selected.nameKo} +
+
+ + {ts.label} + + + {cs.label} + +
+
+ {selected.description} +
+
+
시설명 : {selected.name}
+
유형 : {ts.label}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
); } diff --git a/frontend/src/components/korea/NKLaunchLayer.tsx b/frontend/src/components/korea/NKLaunchLayer.tsx index e4a2c73..009c24e 100644 --- a/frontend/src/components/korea/NKLaunchLayer.tsx +++ b/frontend/src/components/korea/NKLaunchLayer.tsx @@ -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(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 ( - { e.originalEvent.stopPropagation(); setSelected(site); }}> -
-
- {meta.icon} -
-
- {site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo} -
-
-
- ); - })} - - {selected && (() => { - const meta = NK_LAUNCH_TYPE_META[selected.type]; - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="320px" className="gl-popup"> -
-
- 🇰🇵 - {meta.icon} {selected.nameKo} -
-
- - {meta.label} - - - 북한 - -
-
- {selected.description} -
- {selected.recentUse && ( -
- 최근: {selected.recentUse} -
- )} -
- {selected.name} -
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
-
- ); - })()} - + +
+
+ 🇰🇵 + {meta.icon} {selected.nameKo} +
+
+ + {meta.label} + + + 북한 + +
+
+ {selected.description} +
+ {selected.recentUse && ( +
+ 최근: {selected.recentUse} +
+ )} +
+ {selected.name} +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
); } diff --git a/frontend/src/components/korea/NKMissileEventLayer.tsx b/frontend/src/components/korea/NKMissileEventLayer.tsx index 21fcf68..c307645 100644 --- a/frontend/src/components/korea/NKMissileEventLayer.tsx +++ b/frontend/src/components/korea/NKMissileEventLayer.tsx @@ -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(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 유지 */} - {/* 발사 지점 (▲) */} - {NK_MISSILE_EVENTS.map(ev => { - const color = getMissileColor(ev.type); - const today = isToday(ev.date); - return ( - -
- - - -
-
- ); - })} - - {/* 낙하 지점 (✕ + 정보 라벨) */} - {NK_MISSILE_EVENTS.map(ev => { - const color = getMissileColor(ev.type); - const today = isToday(ev.date); - return ( - { e.originalEvent.stopPropagation(); setSelected(ev); }}> -
- - - - - {today && ( - - - - - )} - -
- {ev.date.slice(5)} {ev.time} ← {ev.launchNameKo} -
-
-
- ); - })} - {/* 낙하 지점 팝업 */} {selected && (() => { const color = getMissileColor(selected.type); return ( setSelected(null)} closeOnClick={false} + onClose={onClose} closeOnClick={false} anchor="bottom" maxWidth="340px" className="gl-popup">
diff --git a/frontend/src/components/korea/NavWarningLayer.tsx b/frontend/src/components/korea/NavWarningLayer.tsx index ca8bec2..0f0140b 100644 --- a/frontend/src/components/korea/NavWarningLayer.tsx +++ b/frontend/src/components/korea/NavWarningLayer.tsx @@ -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 = { @@ -19,112 +18,68 @@ const ORG_COLOR: Record = { '국과연': '#eab308', }; -function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) { - const color = ORG_COLOR[org]; - - if (level === 'danger') { - return ( - - - - - - ); - } - - return ( - - - - - - ); +interface Props { + selected: NavWarning | null; + onClose: () => void; } -export function NavWarningLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(w); }}> -
- -
- {w.id} -
-
-
- ); - })} - - {selected && ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="320px" className="gl-popup"> -
-
- {selected.title} -
-
- - {NW_LEVEL_LABEL[selected.level]} - - - {NW_ORG_LABEL[selected.org]} - - - {selected.area} - -
-
- {selected.description} -
-
-
{t('navWarning.altitude')}: {selected.altitude}
-
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
-
{t('navWarning.source')}: {selected.source}
-
- {t('navWarning.khoaLink')} -
-
- )} - + +
+
+ {selected.title} +
+
+ + {NW_LEVEL_LABEL[selected.level]} + + + {NW_ORG_LABEL[selected.org]} + + + {selected.area} + +
+
+ {selected.description} +
+
+
{t('navWarning.altitude')}: {selected.altitude}
+
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
+
{t('navWarning.source')}: {selected.source}
+
+ {t('navWarning.khoaLink')} +
+
); } diff --git a/frontend/src/components/korea/PiracyLayer.tsx b/frontend/src/components/korea/PiracyLayer.tsx index a575c53..65fd727 100644 --- a/frontend/src/components/korea/PiracyLayer.tsx +++ b/frontend/src/components/korea/PiracyLayer.tsx @@ -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 ( - - - - - - - - - - ); +interface Props { + selected: PiracyZone | null; + onClose: () => void; } -export function PiracyLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(zone); }}> -
- -
- {PIRACY_LEVEL_LABEL[zone.level]} -
-
-
- ); - })} + +
+
+ ☠️ + {selected.nameKo} +
- {selected && ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="340px" className="gl-popup"> -
-
- ☠️ - {selected.nameKo} -
+
+ + {PIRACY_LEVEL_LABEL[selected.level]} + + + {selected.name} + + {selected.recentIncidents != null && ( + + {t('piracy.recentIncidents', { count: selected.recentIncidents })} + + )} +
-
- - {PIRACY_LEVEL_LABEL[selected.level]} - - - {selected.name} - - {selected.recentIncidents != null && ( - - {t('piracy.recentIncidents', { count: selected.recentIncidents })} - - )} -
- -
- {selected.description} -
-
- {selected.detail} -
-
- {selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E -
-
-
- )} - +
+ {selected.description} +
+
+ {selected.detail} +
+
+ {selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E +
+
+
); } diff --git a/frontend/src/components/korea/PortLayer.tsx b/frontend/src/components/korea/PortLayer.tsx index d9d3ff4..9019ef5 100644 --- a/frontend/src/components/korea/PortLayer.tsx +++ b/frontend/src/components/korea/PortLayer.tsx @@ -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 = { @@ -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 ( - - - - - - - ); +interface Props { + selected: Port | null; + onClose: () => void; } -export function PortLayer() { - const [selected, setSelected] = useState(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 ( - { e.originalEvent.stopPropagation(); setSelected(p); }}> -
- -
- {p.nameKo.replace('항', '')} -
-
-
- ); - })} - - {selected && (() => { - const s = getStyle(selected); - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
- {s.flag} - ⚓ {selected.nameKo} -
-
- - {selected.type === 'major' ? '주요항만' : '항만'} - - - {s.label} - -
-
-
항구 : {selected.nameKo}
-
영문 : {selected.name}
-
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
- -
-
- ); - })()} - + +
+
+ {s.flag} + ⚓ {selected.nameKo} +
+
+ + {selected.type === 'major' ? '주요항만' : '항만'} + + + {s.label} + +
+
+
항구 : {selected.nameKo}
+
영문 : {selected.name}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+ +
+
); } diff --git a/frontend/src/components/korea/SubmarineCableLayer.tsx b/frontend/src/components/korea/SubmarineCableLayer.tsx index 0c9fed2..1bb58b8 100644 --- a/frontend/src/components/korea/SubmarineCableLayer.tsx +++ b/frontend/src/components/korea/SubmarineCableLayer.tsx @@ -7,6 +7,26 @@ export function SubmarineCableLayer() { const [selectedCable, setSelectedCable] = useState(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), }, })), }; diff --git a/frontend/src/components/korea/WindFarmLayer.tsx b/frontend/src/components/korea/WindFarmLayer.tsx index d2a0648..e83b55a 100644 --- a/frontend/src/components/korea/WindFarmLayer.tsx +++ b/frontend/src/components/korea/WindFarmLayer.tsx @@ -1,95 +1,60 @@ -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 = '#00bcd4'; -function WindTurbineIcon({ size = 18 }: { size?: number }) { - return ( - - - - - - - - - - ); -} - const STATUS_COLOR: Record = { '운영중': '#22c55e', '건설중': '#eab308', '계획': '#64748b', }; -export function WindFarmLayer() { - const [selected, setSelected] = useState(null); +interface Props { + selected: WindFarm | null; + onClose: () => void; +} +export function WindFarmLayer({ selected, onClose }: Props) { + if (!selected) return null; return ( - <> - {KOREA_WIND_FARMS.map(wf => ( - { e.originalEvent.stopPropagation(); setSelected(wf); }}> -
- -
- {wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.name} -
-
-
- ))} - - {selected && ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
- 🌀 - {selected.name} -
-
- - {selected.status} - - - 해상풍력 - - - {selected.region} - -
-
-
용량 : {selected.capacityMW} MW
-
터빈 : {selected.turbines}기
- {selected.year &&
준공 : {selected.year}년
} -
지역 : {selected.region}
-
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
-
- )} - + +
+
+ 🌀 + {selected.name} +
+
+ + {selected.status} + + + 해상풍력 + + + {selected.region} + +
+
+
용량 : {selected.capacityMW} MW
+
터빈 : {selected.turbines}기
+ {selected.year &&
준공 : {selected.year}년
} +
지역 : {selected.region}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
); } diff --git a/frontend/src/components/layers/DeckGLOverlay.tsx b/frontend/src/components/layers/DeckGLOverlay.tsx new file mode 100644 index 0000000..154d59c --- /dev/null +++ b/frontend/src/components/layers/DeckGLOverlay.tsx @@ -0,0 +1,19 @@ +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( + () => new MapboxOverlay({ interleaved: true }), + ); + overlay.setProps({ layers }); + return null; +} diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts new file mode 100644 index 0000000..07d16c8 --- /dev/null +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -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 = { + CRITICAL: [239, 68, 68, 60], + HIGH: [249, 115, 22, 50], + MEDIUM: [234, 179, 8, 40], +}; + +// 테두리색 +const RISK_RGBA_BORDER: Record = { + CRITICAL: [239, 68, 68, 230], + HIGH: [249, 115, 22, 210], + MEDIUM: [234, 179, 8, 190], +}; + +// 픽셀 반경 +const RISK_SIZE: Record = { + CRITICAL: 18, + HIGH: 14, + MEDIUM: 12, +}; + +const RISK_LABEL: Record = { + CRITICAL: '긴급', + HIGH: '경고', + MEDIUM: '주의', +}; + +const RISK_PRIORITY: Record = { + CRITICAL: 0, + HIGH: 1, + MEDIUM: 2, +}; + +/** + * 분석 결과 기반 deck.gl 레이어를 반환하는 훅. + * AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상. + */ +export function useAnalysisDeckLayers( + analysisMap: Map, + 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({ + 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({ + 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({ + 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({ + 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({ + 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]); +} diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts new file mode 100644 index 0000000..7907371 --- /dev/null +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -0,0 +1,893 @@ +import { useMemo } from 'react'; +import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { svgToDataUri } from '../utils/svgToDataUri'; + +// Data imports +import { EAST_ASIA_PORTS } from '../data/ports'; +import type { Port } from '../data/ports'; +import { KOREA_WIND_FARMS } from '../data/windFarms'; +import type { WindFarm } from '../data/windFarms'; +import { MILITARY_BASES } from '../data/militaryBases'; +import type { MilitaryBase } from '../data/militaryBases'; +import { GOV_BUILDINGS } from '../data/govBuildings'; +import type { GovBuilding } from '../data/govBuildings'; +import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../data/nkLaunchSites'; +import type { NKLaunchSite } from '../data/nkLaunchSites'; +import { NK_MISSILE_EVENTS } from '../data/nkMissileEvents'; +import type { NKMissileEvent } from '../data/nkMissileEvents'; +import { COAST_GUARD_FACILITIES } from '../services/coastGuard'; +import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard'; +import { KOREAN_AIRPORTS } from '../services/airports'; +import type { KoreanAirport } from '../services/airports'; +import { NAV_WARNINGS } from '../services/navWarning'; +import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning'; +import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy'; +import type { PiracyZone } from '../services/piracy'; +import type { PowerFacility } from '../services/infra'; + +// ─── Type alias to avoid 'any' in PickingInfo ─────────────────────────────── + +export type StaticPickedObject = + | Port + | WindFarm + | MilitaryBase + | GovBuilding + | NKLaunchSite + | NKMissileEvent + | CoastGuardFacility + | KoreanAirport + | NavWarning + | PiracyZone + | PowerFacility; + +export type StaticLayerKind = + | 'port' + | 'windFarm' + | 'militaryBase' + | 'govBuilding' + | 'nkLaunch' + | 'nkMissile' + | 'coastGuard' + | 'airport' + | 'navWarning' + | 'piracy' + | 'infra'; + +export interface StaticPickInfo { + kind: StaticLayerKind; + object: StaticPickedObject; +} + +interface StaticLayerConfig { + ports: boolean; + coastGuard: boolean; + windFarm: boolean; + militaryBases: boolean; + govBuildings: boolean; + airports: boolean; + navWarning: boolean; + nkLaunch: boolean; + nkMissile: boolean; + piracy: boolean; + infra: boolean; + infraFacilities: PowerFacility[]; + onPick: (info: StaticPickInfo) => void; + sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0) +} + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +function hexToRgb(hex: string): [number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b]; +} + +// ─── Port SVG ──────────────────────────────────────────────────────────────── + +const PORT_COUNTRY_COLOR: Record = { + KR: '#3b82f6', + CN: '#ef4444', + JP: '#f472b6', + KP: '#f97316', + TW: '#10b981', +}; + +function portSvg(color: string, size: number): string { + return ` + + + + + `; +} + +// ─── Wind Turbine SVG ───────────────────────────────────────────────────────── + +const WIND_COLOR = '#00bcd4'; + +function windTurbineSvg(size: number): string { + return ` + + + + + + + + `; +} + +// ─── CoastGuard SVG ─────────────────────────────────────────────────────────── + +const CG_TYPE_COLOR: Record = { + hq: '#ff6b6b', + regional: '#ffa94d', + station: '#4dabf7', + substation: '#69db7c', + vts: '#da77f2', + navy: '#3b82f6', +}; + +function coastGuardSvg(type: CoastGuardType, size: number): string { + const color = CG_TYPE_COLOR[type]; + if (type === 'navy') { + return ` + + + + + `; + } + if (type === 'vts') { + return ` + + + + + `; + } + return ` + + + + + + `; +} + +const CG_TYPE_SIZE: Record = { + hq: 24, + regional: 20, + station: 16, + substation: 13, + vts: 14, + navy: 18, +}; + +// ─── Airport SVG ───────────────────────────────────────────────────────────── + +const AP_COUNTRY_COLOR: Record = { + KR: { intl: '#a78bfa', domestic: '#7c8aaa' }, + CN: { intl: '#ef4444', domestic: '#b91c1c' }, + JP: { intl: '#f472b6', domestic: '#9d174d' }, + KP: { intl: '#f97316', domestic: '#c2410c' }, + TW: { intl: '#10b981', domestic: '#059669' }, +}; + +function apColor(ap: KoreanAirport): string { + const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR; + return ap.intl ? cc.intl : cc.domestic; +} + +function airportSvg(color: string, size: number): string { + return ` + + + `; +} + +// ─── NavWarning SVG ─────────────────────────────────────────────────────────── + +const NW_ORG_COLOR: Record = { + '해군': '#8b5cf6', + '해병대': '#22c55e', + '공군': '#f97316', + '육군': '#ef4444', + '해경': '#3b82f6', + '국과연': '#eab308', +}; + +function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string { + const color = NW_ORG_COLOR[org]; + if (level === 'danger') { + return ` + + + + `; + } + return ` + + + + `; +} + +// ─── Piracy SVG ─────────────────────────────────────────────────────────────── + +function piracySvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +// ─── NKMissile SVG ──────────────────────────────────────────────────────────── + +function getMissileColor(type: string): string { + if (type.includes('ICBM')) return '#dc2626'; + if (type.includes('IRBM')) return '#ef4444'; + if (type.includes('SLBM')) return '#3b82f6'; + return '#f97316'; +} + +function missileLaunchSvg(color: string): string { + return ` + + `; +} + +function missileImpactSvg(color: string): string { + return ` + + + + `; +} + +// ─── Infra SVG ──────────────────────────────────────────────────────────────── + +const INFRA_SOURCE_COLOR: Record = { + nuclear: '#e040fb', + coal: '#795548', + gas: '#ff9800', + oil: '#5d4037', + hydro: '#2196f3', + solar: '#ffc107', + wind: '#00bcd4', + biomass: '#4caf50', +}; +const INFRA_SUBSTATION_COLOR = '#ffeb3b'; + +function infraColor(f: PowerFacility): string { + if (f.type === 'substation') return INFRA_SUBSTATION_COLOR; + return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e'; +} + +function infraSvg(f: PowerFacility): string { + const color = infraColor(f); + if (f.source === 'wind') { + return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`); + } + const size = f.type === 'substation' ? 7 : 12; + return ` + + `; +} + +// ─── Memoized icon atlases ──────────────────────────────────────────────────── + +// We use individual Data URI per item via getIcon accessor instead of atlas +// ─── Main hook ─────────────────────────────────────────────────────────────── + +export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { + return useMemo(() => { + const layers: Layer[] = []; + const sc = config.sizeScale ?? 1.0; // 줌 레벨별 스케일 배율 + + // ── Ports ─────────────────────────────────────────────────────────────── + if (config.ports) { + // Build per-item data-uri icons: reuse by (country, type) key + const portIconCache = new Map(); + function getPortIconUrl(p: Port): string { + const key = `${p.country}-${p.type}`; + if (!portIconCache.has(key)) { + const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR; + const size = p.type === 'major' ? 32 : 24; + portIconCache.set(key, svgToDataUri(portSvg(color, size))); + } + return portIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-ports-icon', + data: EAST_ASIA_PORTS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ + url: getPortIconUrl(d), + width: d.type === 'major' ? 32 : 24, + height: d.type === 'major' ? 32 : 24, + anchorX: d.type === 'major' ? 16 : 12, + anchorY: d.type === 'major' ? 16 : 12, + }), + getSize: (d) => (d.type === 'major' ? 16 : 12) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'port', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-ports-label', + data: EAST_ASIA_PORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.replace('항', ''), + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Wind Farms ───────────────────────────────────────────────────────── + if (config.windFarm) { + const windUrl = svgToDataUri(windTurbineSvg(36)); + layers.push( + new IconLayer({ + id: 'static-windfarm-icon', + data: KOREA_WIND_FARMS, + getPosition: (d) => [d.lng, d.lat], + getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }), + getSize: 18 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'windFarm', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-windfarm-label', + data: KOREA_WIND_FARMS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), + getSize: 9 * sc, + getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Coast Guard ──────────────────────────────────────────────────────── + if (config.coastGuard) { + const cgIconCache = new Map(); + function getCgIconUrl(type: CoastGuardType): string { + if (!cgIconCache.has(type)) { + const size = CG_TYPE_SIZE[type]; + cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2))); + } + return cgIconCache.get(type)!; + } + + layers.push( + new IconLayer({ + id: 'static-coastguard-icon', + data: COAST_GUARD_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = CG_TYPE_SIZE[d.type] * 2; + return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => CG_TYPE_SIZE[d.type] * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'coastGuard', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-coastguard-label', + data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'), + getPosition: (d) => [d.lng, d.lat], + getText: (d) => { + if (d.type === 'vts') return 'VTS'; + if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); + return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; + }, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Airports ─────────────────────────────────────────────────────────── + if (config.airports) { + const apIconCache = new Map(); + function getApIconUrl(ap: KoreanAirport): string { + const color = apColor(ap); + const size = ap.intl ? 40 : 32; + const key = `${color}-${size}`; + if (!apIconCache.has(key)) { + apIconCache.set(key, svgToDataUri(airportSvg(color, size))); + } + return apIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-airports-icon', + data: KOREAN_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.intl ? 40 : 32; + return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.intl ? 20 : 16) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'airport', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-airports-label', + data: KOREAN_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NavWarning ───────────────────────────────────────────────────────── + if (config.navWarning) { + const nwIconCache = new Map(); + function getNwIconUrl(w: NavWarning): string { + const key = `${w.level}-${w.org}`; + if (!nwIconCache.has(key)) { + const size = w.level === 'danger' ? 32 : 28; + nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size))); + } + return nwIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-navwarning-icon', + data: NAV_WARNINGS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.level === 'danger' ? 32 : 28; + return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'navWarning', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-navwarning-label', + data: NAV_WARNINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.id, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 9], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Piracy ───────────────────────────────────────────────────────────── + if (config.piracy) { + const piracyIconCache = new Map(); + function getPiracyIconUrl(zone: PiracyZone): string { + const key = zone.level; + if (!piracyIconCache.has(key)) { + const color = PIRACY_LEVEL_COLOR[zone.level]; + const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40; + piracyIconCache.set(key, svgToDataUri(piracySvg(color, size))); + } + return piracyIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-piracy-icon', + data: PIRACY_ZONES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40; + return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'piracy', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-piracy-label', + data: PIRACY_ZONES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo, + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 14], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Military Bases — TextLayer (이모지) ─────────────────────────────── + if (config.militaryBases) { + const TYPE_COLOR: Record = { + naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', + missile: '#ef4444', joint: '#a78bfa', + }; + const TYPE_ICON: Record = { + naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', + }; + layers.push( + new TextLayer({ + id: 'static-militarybase-emoji', + data: MILITARY_BASES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => TYPE_ICON[d.type] ?? '⭐', + getSize: 14 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'militaryBase', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-militarybase-label', + data: MILITARY_BASES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 9], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── + if (config.govBuildings) { + const GOV_TYPE_COLOR: Record = { + executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', + intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', + }; + const GOV_TYPE_ICON: Record = { + executive: '🏛', legislature: '🏛', military_hq: '⭐', + intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', + }; + layers.push( + new TextLayer({ + id: 'static-govbuilding-emoji', + data: GOV_BUILDINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', + getSize: 12 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'govBuilding', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-govbuilding-label', + data: GOV_BUILDINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── + if (config.nkLaunch) { + layers.push( + new TextLayer({ + id: 'static-nklaunch-emoji', + data: NK_LAUNCH_SITES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀', + getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'nkLaunch', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-nklaunch-label', + data: NK_LAUNCH_SITES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Missile Events — IconLayer ───────────────────────────────────── + if (config.nkMissile) { + // Launch points (triangle) + const launchIconCache = new Map(); + function getLaunchIconUrl(type: string): string { + if (!launchIconCache.has(type)) { + launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); + } + return launchIconCache.get(type)!; + } + // Impact points (X) + const impactIconCache = new Map(); + function getImpactIconUrl(type: string): string { + if (!impactIconCache.has(type)) { + impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type)))); + } + return impactIconCache.get(type)!; + } + + interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number } + interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number } + + const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng })); + const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng })); + + // 발사→착탄 궤적선 + const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({ + path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][], + color: hexToRgb(getMissileColor(ev.type)), + })); + layers.push( + new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({ + id: 'static-nkmissile-trajectory', + data: trajectoryData, + getPath: (d) => d.path, + getColor: (d) => [...d.color, 150] as [number, number, number, number], + getWidth: 2, + widthUnits: 'pixels', + getDashArray: [6, 3], + dashJustified: true, + extensions: [], + }), + ); + + layers.push( + new IconLayer({ + id: 'static-nkmissile-launch', + data: launchData, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }), + getSize: 12 * sc, + getColor: (d) => { + const today = new Date().toISOString().slice(0, 10) === d.ev.date; + return [255, 255, 255, today ? 255 : 90] as [number, number, number, number]; + }, + }), + new IconLayer({ + id: 'static-nkmissile-impact', + data: impactData, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }), + getSize: 16 * sc, + getColor: (d) => { + const today = new Date().toISOString().slice(0, 10) === d.ev.date; + return [255, 255, 255, today ? 255 : 100] as [number, number, number, number]; + }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'nkMissile', object: info.object.ev }); + return true; + }, + }), + new TextLayer({ + id: 'static-nkmissile-label', + data: impactData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Infra ────────────────────────────────────────────────────────────── + if (config.infra && config.infraFacilities.length > 0) { + const infraIconCache = new Map(); + function getInfraIconUrl(f: PowerFacility): string { + const key = `${f.type}-${f.source ?? ''}`; + if (!infraIconCache.has(key)) { + infraIconCache.set(key, svgToDataUri(infraSvg(f))); + } + return infraIconCache.get(key)!; + } + + const plants = config.infraFacilities.filter(f => f.type === 'plant'); + const substations = config.infraFacilities.filter(f => f.type === 'substation'); + + layers.push( + new IconLayer({ + id: 'static-infra-substation', + data: substations, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), + getSize: 7 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new IconLayer({ + id: 'static-infra-plant', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), + getSize: 12 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-infra-plant-label', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; + // infraFacilities는 배열 참조가 바뀌어야 갱신 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + config.ports, + config.windFarm, + config.coastGuard, + config.airports, + config.navWarning, + config.piracy, + config.militaryBases, + config.govBuildings, + config.nkLaunch, + config.nkMissile, + config.infra, + config.infraFacilities, + config.onPick, + config.sizeScale, + ]); +} + +// Re-export types that KoreaMap will need for Popup rendering +export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility }; +// Re-export label/color helpers used in Popup rendering +export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor }; diff --git a/frontend/src/utils/svgToDataUri.ts b/frontend/src/utils/svgToDataUri.ts new file mode 100644 index 0000000..9d6f96c --- /dev/null +++ b/frontend/src/utils/svgToDataUri.ts @@ -0,0 +1,3 @@ +export function svgToDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} From 8bda28697591c6b6ea8daeca3f22b9d568e7af09 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 21:14:16 +0900 Subject: [PATCH 39/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 8ab53e8..a76efaf 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,21 @@ ## [Unreleased] +### 변경 +- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL) +- 정적 마커 11종 deck.gl IconLayer/TextLayer 전환 (항구/공항/군사기지/미사일 등) +- 분석 오버레이 deck.gl ScatterplotLayer/TextLayer 전환 +- 줌 레벨별 아이콘 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x) + +### 추가 +- NK 미사일 발사→착탄 궤적선 (PathLayer) +- 정적 마커 클릭 정보 Popup +- 선택 어구그룹 어구 위치 마커 + 모선 강조 (deck.gl) + +### 수정 +- 해저케이블 날짜변경선(180도) 좌표 보정 +- 렌더링 성능 대폭 개선 (DOM 오버헤드 제거) + ## [2026-03-20.2] ### 추가 From 109a2068abfbbb58bb4b83fcfdf59a21b79396d1 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 21:20:02 +0900 Subject: [PATCH 40/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-20.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a76efaf..3848b4e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,20 +4,17 @@ ## [Unreleased] +## [2026-03-20.3] + ### 변경 - deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL) -- 정적 마커 11종 deck.gl IconLayer/TextLayer 전환 (항구/공항/군사기지/미사일 등) -- 분석 오버레이 deck.gl ScatterplotLayer/TextLayer 전환 -- 줌 레벨별 아이콘 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x) +- 정적 마커 11종 deck.gl 전환 + 줌 레벨별 스케일 ### 추가 -- NK 미사일 발사→착탄 궤적선 (PathLayer) -- 정적 마커 클릭 정보 Popup -- 선택 어구그룹 어구 위치 마커 + 모선 강조 (deck.gl) +- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조 ### 수정 -- 해저케이블 날짜변경선(180도) 좌표 보정 -- 렌더링 성능 대폭 개선 (DOM 오버헤드 제거) +- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 ## [2026-03-20.2] From 2f0ff22d1bc08c2f46ce4ab08174c02a157d270d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:19:54 +0900 Subject: [PATCH 41/46] =?UTF-8?q?feat:=20=ED=95=9C=EA=B5=AD=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=ED=95=B5=EC=8B=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=E2=80=94=20=ED=95=B4=EC=99=B8=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=C2=B7=ED=98=84=EC=9E=A5=EB=B6=84=EC=84=9D=C2=B7?= =?UTF-8?q?=EC=84=A0=EB=8B=A8=EA=B0=95=EC=A1=B0=C2=B7=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 5 + frontend/src/App.tsx | 86 +- frontend/src/components/common/EventLog.tsx | 56 +- .../src/components/korea/CnFacilityLayer.tsx | 82 ++ .../components/korea/FieldAnalysisModal.tsx | 855 ++++++++++++++++++ .../components/korea/FleetClusterLayer.tsx | 211 ++++- .../components/korea/HazardFacilityLayer.tsx | 110 +++ .../src/components/korea/JpFacilityLayer.tsx | 81 ++ frontend/src/components/korea/KoreaMap.tsx | 110 ++- frontend/src/components/layers/ShipLayer.tsx | 7 +- frontend/src/data/cnFacilities.ts | 141 +++ frontend/src/data/hazardFacilities.ts | 520 +++++++++++ frontend/src/data/jpFacilities.ts | 150 +++ frontend/src/hooks/useStaticDeckLayers.ts | 197 +++- frontend/src/i18n/locales/ko/common.json | 2 +- frontend/src/services/disasterNews.ts | 146 +++ frontend/src/services/osint.ts | 397 +++++--- frontend/src/types.ts | 10 + prediction/cache/vessel_store.py | 19 +- 19 files changed, 3055 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/korea/CnFacilityLayer.tsx create mode 100644 frontend/src/components/korea/FieldAnalysisModal.tsx create mode 100644 frontend/src/components/korea/HazardFacilityLayer.tsx create mode 100644 frontend/src/components/korea/JpFacilityLayer.tsx create mode 100644 frontend/src/data/cnFacilities.ts create mode 100644 frontend/src/data/hazardFacilities.ts create mode 100644 frontend/src/data/jpFacilities.ts create mode 100644 frontend/src/services/disasterNews.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 231350f..de8a637 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1881,6 +1881,11 @@ border-top-color: rgba(10, 10, 26, 0.96) !important; } +/* 중국어선 오버레이 마커 — 이벤트 차단 */ +.maplibregl-marker:has(.cn-fishing-no-events) { + pointer-events: none; +} + .gl-popup .maplibregl-popup-close-button, .event-popup .maplibregl-popup-close-button { color: #aaa !important; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca797f4..241a5a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { useAuth } from './hooks/useAuth'; import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; +import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; import './App.css'; function App() { @@ -65,6 +66,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { oilFacilities: true, meFacilities: true, militaryOnly: false, + overseasUS: false, + overseasUK: false, + overseasIran: false, + overseasUAE: false, + overseasSaudi: false, + overseasOman: false, + overseasQatar: false, + overseasKuwait: false, + overseasIraq: false, + overseasBahrain: false, }); // Korea tab layer visibility (lifted from KoreaMap) @@ -89,6 +100,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { nkMissile: true, cnFishing: false, militaryOnly: false, + overseasChina: false, + overseasJapan: false, + cnPower: false, + cnMilitary: false, + jpPower: false, + jpMilitary: false, + hazardPetrochemical: false, + hazardLng: false, + hazardOilTank: false, + hazardPort: false, + energyNuclear: false, + energyThermal: false, + industryShipyard: false, + industryWastewater: false, + industryHeavy: false, }); const toggleKoreaLayer = useCallback((key: string) => { @@ -148,6 +174,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, []); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); @@ -321,6 +348,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 🎣 중국어선감시 +
)} @@ -459,6 +495,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, ]} + 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} @@ -553,6 +601,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { <>
+ {showFieldAnalysis && ( + setShowFieldAnalysis(false)} /> + )} = { }; const EMPTY_OSINT: OsintItem[] = []; -const EMPTY_SHIPS: import('../types').Ship[] = []; +const EMPTY_SHIPS: Ship[] = []; function useTimeAgo() { const { t } = useTranslation('common'); @@ -597,7 +598,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {'\u{1F1F0}\u{1F1F7}'} {t('ships:shipStatus.koreanTitle')} {koreanShips.length}{t('common:units.vessels')} - {onToggleHighlightKorean && dashboardTab === 'iran' && ( + {onToggleHighlightKorean && (dashboardTab as string) === 'iran' && ( +
+
+ + {/* ── 통계 스트립 */} +
+ {[ + { label: '총 탐지 어선', val: stats.total, color: C.cyan, sub: 'AIS 수신 기준' }, + { 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 스푸핑 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 }) => ( +
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ + {/* ── 메인 그리드 */} +
+ {/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */} +
+
+ 구역별 현황 + +
+ + {([ + { label: '영해 (12NM)', count: zoneCounts.terr, color: C.red, sub: '즉시 퇴거 명령 필요' }, + { label: '접속수역 (24NM)', count: zoneCounts.cont, color: C.amber, sub: '조업 행위 집중 모니터링' }, + { label: 'EEZ 내측', count: zoneCounts.eez, color: C.amber, sub: '조업밀도 핫스팟 포함' }, + { label: 'EEZ 외측', count: zoneCounts.beyond, color: C.green, sub: '정상 모니터링' }, + ] as const).map(({ label, count, color, sub }) => { + const max = Math.max(processed.length, 1); + return ( +
+
+ {label} + {count} +
+
+
+
+
{sub}
+
+ ); + })} + +
+ AI 파이프라인 상태 + +
+ + {PIPE_STEPS.map((step, idx) => { + const isRunning = idx === pipeStep % PIPE_STEPS.length; + return ( +
+ {step.num} + {step.name} + + {isRunning ? 'PROC' : 'OK'} + +
+ ); + })} + + {[ + { num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 }, + { num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 }, + ].map(step => ( +
+ {step.num} + {step.name} + + {step.status} + +
+ ))} + + {/* 알고리즘 기준 요약 */} +
+ 알고리즘 기준 +
+ {[ + { label: '위치 판정', val: 'Haversine + 기선', color: C.ink2 }, + { 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: 'DBSCAN 3NM (Python)', color: C.ink2 }, + { label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green }, + ].map(({ label, val, color }) => ( +
+ {label} + {val} +
+ ))} +
+ + {/* ── 중앙 패널: 선박 테이블 */} +
+ {/* 필터 바 */} +
+ {[ + { key: 'ALL', label: '전체' }, + { key: 'CRITICAL', label: '긴급 경보' }, + { key: 'FISHING', label: '조업 중' }, + { key: 'AIS_LOSS', label: 'AIS 소실' }, + { key: 'TERRITORIAL', label: '영해 내' }, + ].map(({ key, label }) => ( + + ))} + setSearch(e.target.value.toLowerCase())} + placeholder="MMSI / 선명 검색..." + style={{ + flex: 1, minWidth: 120, + background: C.bg3, border: `1px solid ${C.border}`, + color: C.ink, padding: '3px 10px', fontSize: 10, + borderRadius: 2, outline: 'none', fontFamily: 'inherit', + }} + /> + + 표시: {displayed.length} 척 + + +
+ + {/* 테이블 */} +
+ + + + {['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => ( + + ))} + + + + {displayed.slice(0, 120).map(v => { + const rowBg = + v.alert === 'CRITICAL' ? 'rgba(255,82,82,0.08)' : + v.alert === 'WATCH' ? 'rgba(255,215,64,0.05)' : + v.alert === 'MONITOR' ? 'rgba(24,255,255,0.04)' : + 'transparent'; + const isSelected = v.ship.mmsi === selectedMmsi; + const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000); + return ( + setSelectedMmsi(v.ship.mmsi)} + style={{ + background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg, + cursor: 'pointer', + outline: isSelected ? `1px solid ${C.green}` : undefined, + }} + > + + + + + + + + + + + + + + + ); + })} + {displayed.length === 0 && ( + + + + )} + +
{h}
+ + {v.ship.mmsi} + {v.ship.name || '(Unknown)'} + {v.ship.lat.toFixed(3)}°N{v.ship.lng.toFixed(3)}°E + {v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`} + + {v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'} + + + {stateLabel(v.state)} + + + + {v.vtype} + + + + {zoneLabel(v.zone)} + + + {v.cluster} + + + {v.alert} + + + {ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`} +
+ 탐지된 중국 어선 없음 +
+
+ + {/* 하단 범례 */} +
+ {[ + { color: C.red, label: 'CRITICAL — 즉시대응' }, + { color: C.amber, label: 'WATCH — 집중모니터링' }, + { color: C.cyan, label: 'MONITOR — 주시' }, + { color: C.green, label: 'NORMAL — 정상' }, + ].map(({ color, label }) => ( + + + {label} + + ))} + + AIS 4분 갱신 | Python 7단계 파이프라인 | DBSCAN 3NM 클러스터 | GeoJSON 수역 분류 + +
+
+ + {/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */} +
+ {/* 패널 헤더 */} +
+ 선박 상세 정보 + +
+ + {/* 스크롤 영역: 상세 + 허가 + 사진 */} +
+ {selectedVessel ? ( + <> + {/* 기본 상세 필드 */} +
+ {[ + { label: 'MMSI', val: selectedVessel.ship.mmsi, color: C.cyan }, + { label: '선명', val: selectedVessel.ship.name || '(Unknown)', color: '#fff' }, + { 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: '선종 (Python)', val: selectedVessel.vtype, color: C.ink }, + { label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) }, + { 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 }) => ( +
+ {label} + {val} +
+ ))} +
+ + +
+
+ + {/* ── 허가 정보 */} +
+
허가 정보
+ + {/* 허가 여부 배지 */} +
+ 허가 여부 + {permitStatus === 'loading' && ( + 조회 중... + )} + {permitStatus === 'found' && ( + + ✓ 허가 선박 + + )} + {permitStatus === 'not-found' && ( + + ✕ 미등록 선박 + + )} +
+ + {/* 허가 내역 (데이터 있을 때) */} + {permitStatus === 'found' && permitData && ( +
+ {[ + { label: '선명', val: permitData.name }, + { label: '선종', val: permitData.vesselType }, + { label: 'IMO', val: String(permitData.imo || '—') }, + { label: '호출부호', val: permitData.callsign || '—' }, + { label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` }, + { label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' }, + { label: '목적지', val: permitData.destination || '—' }, + { label: '상태', val: permitData.status || '—' }, + ].map(({ label, val }) => ( +
+ {label} + {val} +
+ ))} +
+ )} + + {/* 미등록 안내 */} + {permitStatus === 'not-found' && ( +
+
+ 한중어업협정 허가 DB에 등록되지 않은 선박입니다.
+ 불법어업 의심 — 추가 조사 및 조치 필요 +
+
+ )} +
+ + {/* ── 선박 사진 */} +
+
선박 사진
+
+ {photoUrl === undefined && ( + 로딩 중... + )} + {photoUrl === null && ( + 사진 없음 + )} + {photoUrl && ( + {selectedVessel.ship.name setPhotoUrl(null)} + /> + )} +
+ {photoUrl && ( +
+ © MarineTraffic / S&P Global +
+ )} +
+ + ) : ( +
+ 테이블에서 선박을 선택하세요 +
+ )} +
+ + {/* 경보 로그 — 하단 고정 */} +
+ 실시간 경보 로그 + {logs.length}건 +
+
+ {logs.map((log, i) => ( +
+
{log.ts}
+
+ {log.mmsi} {log.name} — {log.type} +
+
+ ))} + {logs.length === 0 && ( +
경보 없음
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 79f93fe..7c61c13 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Source, Layer } from 'react-map-gl/maplibre'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre'; import type { GeoJSON } from 'geojson'; +import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; @@ -11,6 +12,12 @@ export interface SelectedGearGroupData { groupName: string; } +export interface SelectedFleetData { + clusterId: number; + ships: Ship[]; + companyName: string; +} + interface Props { ships: Ship[]; analysisMap: Map; @@ -18,6 +25,7 @@ interface Props { onShipSelect?: (mmsi: string) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; + onSelectedFleetChange?: (data: SelectedFleetData | null) => void; } // 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 @@ -90,18 +98,135 @@ interface ClusterLineFeature { type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; -export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange }: Props) { +export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { const [companies, setCompanies] = useState>(new Map()); const [expanded, setExpanded] = useState(true); const [expandedFleet, setExpandedFleet] = useState(null); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); + // 폴리곤 호버 툴팁 + const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); + const { current: mapRef } = useMap(); + const registeredRef = useRef(false); + // dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조) + const dataRef = useRef<{ clusters: Map; shipMap: Map; gearGroupMap: Map; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom }); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); }, []); + // ── 맵 폴리곤 클릭/호버 이벤트 등록 + useEffect(() => { + const map = mapRef?.getMap(); + if (!map || registeredRef.current) return; + + const fleetLayers = ['fleet-cluster-fill-layer']; + const gearLayers = ['gear-cluster-fill-layer']; + const allLayers = [...fleetLayers, ...gearLayers]; + + const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; }; + + const onFleetEnter = (e: MapLayerMouseEvent) => { + setCursor('pointer'); + const feat = e.features?.[0]; + if (!feat) return; + const cid = feat.properties?.clusterId as number | undefined; + if (cid != null) { + setHoveredFleetId(cid); + setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid }); + } + }; + const onFleetLeave = () => { + setCursor(''); + setHoveredFleetId(null); + setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev); + }; + const onFleetClick = (e: MapLayerMouseEvent) => { + const feat = e.features?.[0]; + if (!feat) return; + const cid = feat.properties?.clusterId as number | undefined; + if (cid == null) return; + const d = dataRef.current; + setExpandedFleet(prev => prev === cid ? null : cid); + setExpanded(true); + const mmsiList = d.clusters.get(cid) ?? []; + if (mmsiList.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const mmsi of mmsiList) { + const ship = d.shipMap.get(mmsi); + if (!ship) continue; + if (ship.lat < minLat) minLat = ship.lat; + if (ship.lat > maxLat) maxLat = ship.lat; + if (ship.lng < minLng) minLng = ship.lng; + if (ship.lng > maxLng) maxLng = ship.lng; + } + if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }; + + const onGearEnter = (e: MapLayerMouseEvent) => { + setCursor('pointer'); + const feat = e.features?.[0]; + if (!feat) return; + const name = feat.properties?.name as string | undefined; + if (name) { + setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name }); + } + }; + const onGearLeave = () => { + setCursor(''); + setHoverTooltip(prev => prev?.type === 'gear' ? null : prev); + }; + const onGearClick = (e: MapLayerMouseEvent) => { + const feat = e.features?.[0]; + if (!feat) return; + const name = feat.properties?.name as string | undefined; + if (!name) return; + const d = dataRef.current; + setSelectedGearGroup(prev => prev === name ? null : name); + setExpandedGearGroup(name); + setExpanded(true); + const entry = d.gearGroupMap.get(name); + if (!entry) return; + const all: Ship[] = [...entry.gears]; + if (entry.parent) all.push(entry.parent); + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const s of all) { + if (s.lat < minLat) minLat = s.lat; + if (s.lat > maxLat) maxLat = s.lat; + if (s.lng < minLng) minLng = s.lng; + if (s.lng > maxLng) maxLng = s.lng; + } + if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }; + + const register = () => { + const ready = allLayers.every(id => map.getLayer(id)); + if (!ready) return; + registeredRef.current = true; + + for (const id of fleetLayers) { + map.on('mouseenter', id, onFleetEnter); + map.on('mouseleave', id, onFleetLeave); + map.on('click', id, onFleetClick); + } + for (const id of gearLayers) { + map.on('mouseenter', id, onGearEnter); + map.on('mouseleave', id, onGearLeave); + map.on('click', id, onGearClick); + } + }; + + register(); + if (!registeredRef.current) { + const interval = setInterval(() => { + register(); + if (registeredRef.current) clearInterval(interval); + }, 500); + return () => clearInterval(interval); + } + }, [mapRef]); + // 선박명 → mmsi 맵 (어구 매칭용) const gearsByParent = useMemo(() => { const map = new Map(); // parent_mmsi → gears @@ -180,6 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, return map; }, [ships]); + // stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신 + dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom }; + // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) useEffect(() => { if (!selectedGearGroup) { @@ -194,6 +322,22 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, } }, [selectedGearGroup, gearGroupMap, onSelectedGearChange]); + // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) + useEffect(() => { + if (expandedFleet === null) { + onSelectedFleetChange?.(null); + return; + } + const mmsiList = clusters.get(expandedFleet) ?? []; + const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s); + const company = companies.get(expandedFleet); + onSelectedFleetChange?.({ + clusterId: expandedFleet, + ships: fleetShips, + companyName: company?.nameCn || `선단 #${expandedFleet}`, + }); + }, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); + // 비허가 어구 클러스터 GeoJSON const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; @@ -457,6 +601,67 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> + {/* 폴리곤 호버 툴팁 */} + {hoverTooltip && (() => { + if (hoverTooltip.type === 'fleet') { + const cid = hoverTooltip.id as number; + const mmsiList = clusters.get(cid) ?? []; + const company = companies.get(cid); + const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + return ( + +
+
+ {company?.nameCn || `선단 #${cid}`} +
+
선박 {mmsiList.length}척 · 어구 {gearCount}개
+ {expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => { + const s = shipMap.get(mmsi); + const dto = analysisMap.get(mmsi); + const role = dto?.algorithms.fleetRole.role ?? ''; + return s ? ( +
+ {role === 'LEADER' ? '★' : '·'} {s.name || mmsi} {s.speed?.toFixed(1)}kt +
+ ) : null; + })} +
클릭하여 상세 보기
+
+
+ ); + } + if (hoverTooltip.type === 'gear') { + const name = hoverTooltip.id as string; + const entry = gearGroupMap.get(name); + if (!entry) return null; + return ( + +
+
+ {name} 어구 {entry.gears.length}개 +
+ {entry.parent && ( +
모선: {entry.parent.name || entry.parent.mmsi}
+ )} + {selectedGearGroup === name && entry.gears.slice(0, 5).map(g => ( +
+ · {g.name || g.mmsi} +
+ ))} +
클릭하여 선택/해제
+
+
+ ); + } + return null; + })()} + {/* 선단 목록 패널 */}
diff --git a/frontend/src/components/korea/HazardFacilityLayer.tsx b/frontend/src/components/korea/HazardFacilityLayer.tsx new file mode 100644 index 0000000..f4e4923 --- /dev/null +++ b/frontend/src/components/korea/HazardFacilityLayer.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { HAZARD_FACILITIES } from '../../data/hazardFacilities'; +import type { HazardFacility, HazardType } from '../../data/hazardFacilities'; + +interface Props { + type: HazardType; +} + +const TYPE_META: Record = { + petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지', bgColor: 'rgba(249,115,22,0.15)' }, + lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지', bgColor: 'rgba(6,182,212,0.15)' }, + oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크', bgColor: 'rgba(234,179,8,0.15)' }, + hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물하역시설', bgColor: 'rgba(239,68,68,0.15)' }, + nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소', bgColor: 'rgba(168,85,247,0.15)' }, + thermal: { icon: '🔥', color: '#64748b', label: '화력발전소', bgColor: 'rgba(100,116,139,0.15)' }, + shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소 도장시설', bgColor: 'rgba(14,165,233,0.15)' }, + wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장', bgColor: 'rgba(16,185,129,0.15)' }, + heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소', bgColor: 'rgba(148,163,184,0.15)' }, +}; + +export function HazardFacilityLayer({ type }: Props) { + const [selected, setSelected] = useState(null); + const meta = TYPE_META[type]; + const facilities = HAZARD_FACILITIES.filter(f => f.type === type); + + return ( + <> + {facilities.map(f => ( + { e.originalEvent.stopPropagation(); setSelected(f); }}> +
+
+ {meta.icon} +
+
+ {f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo} +
+
+
+ ))} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup" + > +
+
+ {meta.icon} + {selected.nameKo} +
+ +
+ + {meta.label} + + + ⚠️ 위험시설 + +
+ +
+ {selected.description} +
+ +
+ {selected.address && ( +
주소 : {selected.address}
+ )} + {selected.operator && ( +
운영자 : {selected.operator}
+ )} + {selected.capacity && ( +
처리규모 : {selected.capacity}
+ )} +
시설명(EN) : {selected.name}
+
+ +
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/korea/JpFacilityLayer.tsx b/frontend/src/components/korea/JpFacilityLayer.tsx new file mode 100644 index 0000000..0725ec0 --- /dev/null +++ b/frontend/src/components/korea/JpFacilityLayer.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES, type JpFacility } from '../../data/jpFacilities'; + +interface Props { + type: 'power' | 'military'; +} + +const SUBTYPE_META: Record = { + nuclear: { color: '#a855f7', icon: '☢', label: '핵발전' }, + thermal: { color: '#f97316', icon: '⚡', label: '화력발전' }, + naval: { color: '#3b82f6', icon: '⚓', label: '해군기지' }, + airbase: { color: '#22d3ee', icon: '✈', label: '공군기지' }, + army: { color: '#ef4444', icon: '★', label: '육군' }, +}; + +export function JpFacilityLayer({ type }: Props) { + const [popup, setPopup] = useState(null); + const facilities = type === 'power' ? JP_POWER_PLANTS : JP_MILITARY_FACILITIES; + + return ( + <> + {facilities.map(f => { + const meta = SUBTYPE_META[f.subType]; + return ( + { e.originalEvent.stopPropagation(); setPopup(f); }} + > +
+ {meta.icon} +
+
+ ); + })} + + {popup && ( + setPopup(null)} + closeOnClick={false} + maxWidth="220px" + > +
+
{popup.name}
+
+ {SUBTYPE_META[popup.subType].label} +
+ {popup.operator && ( +
운영: {popup.operator}
+ )} + {popup.description && ( +
{popup.description}
+ )} +
+
+ )} + + ); +} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index d459fc5..583d736 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -20,9 +20,10 @@ import { EezLayer } from './EezLayer'; // PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer, // NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨 import { ChineseFishingOverlay } from './ChineseFishingOverlay'; +// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨 import { AnalysisOverlay } from './AnalysisOverlay'; import { FleetClusterLayer } from './FleetClusterLayer'; -import type { SelectedGearGroupData } from './FleetClusterLayer'; +import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer'; import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -138,6 +139,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); const [selectedGearData, setSelectedGearData] = useState(null); + const [selectedFleetData, setSelectedFleetData] = useState(null); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const [staticPickInfo, setStaticPickInfo] = useState(null); @@ -273,6 +275,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF 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, }); @@ -353,6 +370,88 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF 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' @@ -502,6 +601,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} {koreaFilters.illegalFishing && } {layers.cnFishing && } + {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( @@ -528,6 +629,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF illegalFishingLabelLayer, zoneLabelsLayer, ...selectedGearLayers, + ...selectedFleetLayers, ...analysisDeckLayers, ].filter(Boolean)} /> {/* 정적 마커 클릭 Popup */} @@ -552,6 +654,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {obj.range &&
사거리: {obj.range}
} {obj.operator &&
운영: {obj.operator}
} {obj.capacity &&
용량: {obj.capacity}
} + {staticPickInfo.kind === 'hazard' && obj.address && ( +
📍 {obj.address}
+ )} + {(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && ( +
유형: {obj.subType}
+ )}
); diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 10b0164..664ee33 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -526,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM return ( <> - {/* Hovered ship highlight ring */} + {/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */} diff --git a/frontend/src/data/cnFacilities.ts b/frontend/src/data/cnFacilities.ts new file mode 100644 index 0000000..c7b9dec --- /dev/null +++ b/frontend/src/data/cnFacilities.ts @@ -0,0 +1,141 @@ +export interface CnFacility { + id: string; + name: string; + subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army' | 'shipyard'; + lat: number; + lng: number; + operator?: string; + description?: string; +} + +export const CN_POWER_PLANTS: CnFacility[] = [ + { + id: 'cn-npp-hongyanhe', + name: '홍옌허(红沿河) 핵발전소', + subType: 'nuclear', + lat: 40.87, + lng: 121.02, + operator: '中国大唐集团', + description: '가압경수로 6기, 라오닝성 — 한반도 최근접 핵발전소', + }, + { + id: 'cn-npp-tianwan', + name: '톈완(田湾) 핵발전소', + subType: 'nuclear', + lat: 34.71, + lng: 119.45, + operator: '江苏核电', + description: '러시아 VVER-1000 설계, 장쑤성', + }, + { + id: 'cn-npp-qinshan', + name: '진산(秦山) 핵발전소', + subType: 'nuclear', + lat: 30.44, + lng: 120.96, + operator: '中核集团', + description: '중국 최초 상업 핵발전소, 저장성', + }, + { + id: 'cn-npp-ningde', + name: '닝더(宁德) 핵발전소', + subType: 'nuclear', + lat: 26.73, + lng: 120.12, + operator: '中国大唐集团', + description: '가압경수로 4기, 푸젠성', + }, + { + id: 'cn-thermal-dalian', + name: '다롄 화력발전소', + subType: 'thermal', + lat: 38.85, + lng: 121.55, + operator: '大连电力', + description: '석탄화력, 라오닝성', + }, + { + id: 'cn-thermal-qinhuangdao', + name: '친황다오 화력발전소', + subType: 'thermal', + lat: 39.93, + lng: 119.58, + operator: '华能国际', + description: '석탄화력 대형 기지, 허베이성', + }, + { + id: 'cn-thermal-tianjin', + name: '톈진 화력발전소', + subType: 'thermal', + lat: 39.08, + lng: 117.20, + operator: '华能集团', + description: '석탄화력, 톈진시', + }, +]; + +export const CN_MILITARY_FACILITIES: CnFacility[] = [ + { + id: 'cn-mil-qingdao', + name: '칭다오 해군기지', + subType: 'naval', + lat: 36.07, + lng: 120.26, + operator: '해군 북부전구', + description: '항모전단 모항, 핵잠수함 기지', + }, + { + id: 'cn-mil-lushun', + name: '뤼순(旅順) 해군기지', + subType: 'naval', + lat: 38.85, + lng: 121.24, + operator: '해군 북부전구', + description: '잠수함·구축함 기지, 보하이만 입구', + }, + { + id: 'cn-mil-dalian-shipyard', + name: '다롄 조선소 (항모건조)', + subType: 'shipyard', + lat: 38.92, + lng: 121.62, + operator: '中国船舶重工', + description: '랴오닝함·산둥함 건조, 항모 4번함 건조 중', + }, + { + id: 'cn-mil-shenyang-cmd', + name: '북부전구 사령부', + subType: 'army', + lat: 41.80, + lng: 123.42, + operator: '해방군 북부전구', + description: '한반도·동북아 담당, 선양시', + }, + { + id: 'cn-mil-shenyang-air', + name: '선양 공군기지', + subType: 'airbase', + lat: 41.77, + lng: 123.49, + operator: '공군 북부전구', + description: 'J-16 전투기 배치, 북부전구 핵심기지', + }, + { + id: 'cn-mil-dandong', + name: '단둥 군사시설', + subType: 'army', + lat: 40.13, + lng: 124.38, + operator: '해방군 육군', + description: '북중 접경 전진기지, 한반도 작전 담당', + }, + { + id: 'cn-mil-zhoushan', + name: '저우산 해군기지', + subType: 'naval', + lat: 30.00, + lng: 122.10, + operator: '해군 동부전구', + description: '동중국해 주력 함대 기지', + }, +]; diff --git a/frontend/src/data/hazardFacilities.ts b/frontend/src/data/hazardFacilities.ts new file mode 100644 index 0000000..45ca368 --- /dev/null +++ b/frontend/src/data/hazardFacilities.ts @@ -0,0 +1,520 @@ +export type HazardType = 'petrochemical' | 'lng' | 'oilTank' | 'hazardPort' | 'nuclear' | 'thermal' | 'shipyard' | 'wastewater' | 'heavyIndustry'; + +export interface HazardFacility { + id: string; + type: HazardType; + nameKo: string; + name: string; + lat: number; + lng: number; + address?: string; + capacity?: string; + operator?: string; + description: string; +} + +export const HAZARD_FACILITIES: HazardFacility[] = [ + // ── 해안인접석유화학단지 ────────────────────────────────────────── + { + id: 'pc-01', type: 'petrochemical', + nameKo: '여수국가산업단지', name: 'Yeosu National Industrial Complex', + lat: 34.757, lng: 127.723, + address: '전남 여수시 화치동 산 183-1', + capacity: '연산 2,400만 톤', operator: '여수광양항만공사·LG화학·롯데케미칼', + description: '국내 최대 석유화학단지. NCC·LG화학·롯데케미칼·GS칼텍스 등 입주.', + }, + { + id: 'pc-02', type: 'petrochemical', + nameKo: '울산미포국가산업단지', name: 'Ulsan Mipo National Industrial Complex', + lat: 35.479, lng: 129.357, + address: '울산광역시 남구 사평로 137 (부곡동 439-1)', + capacity: '연산 1,800만 톤', operator: 'S-OIL·SK에너지·SK지오센트릭', + description: '정유·NCC 중심 울산미포국가산단 내 석유화학 집적지.', + }, + { + id: 'pc-03', type: 'petrochemical', + nameKo: '대산석유화학단지', name: 'Daesan Petrochemical Complex', + lat: 37.025, lng: 126.360, + address: '충남 서산시 대산읍 독곶1로 82 (롯데케미칼 대산공장 기준)', + capacity: '연산 900만 톤', operator: '롯데케미칼·현대오일뱅크·한화토탈에너지스', + description: '충남 서산 대산항 인근 3대 석유화학단지.', + }, + { + id: 'pc-04', type: 'petrochemical', + nameKo: '광양 석유화학단지', name: 'Gwangyang Petrochemical Complex', + lat: 34.970, lng: 127.705, + capacity: '연산 600만 톤', operator: 'POSCO·포스코케미칼', + description: '광양제철소 연계 석유화학 시설.', + }, + { + id: 'pc-05', type: 'petrochemical', + nameKo: '인천 석유화학단지', name: 'Incheon Petrochemical Complex', + lat: 37.470, lng: 126.618, + capacity: '연산 400만 톤', operator: 'SK인천석유화학', + description: '인천 북항 인근 정유·석유화학 시설.', + }, + + // ── LNG 생산기지 (한국가스공사 KOGAS) ──────────────────────────── + { + id: 'lng-01', type: 'lng', + nameKo: '평택 LNG 생산기지', name: 'Pyeongtaek LNG Production Base', + lat: 37.017, lng: 126.870, + address: '경기도 평택시 포승읍', + operator: '한국가스공사(KOGAS)', + description: '국내 최초의 LNG 기지. 수도권 공급의 핵심 거점.', + }, + { + id: 'lng-02', type: 'lng', + nameKo: '인천 LNG 생산기지', name: 'Incheon LNG Production Base', + lat: 37.374, lng: 126.622, + address: '인천광역시 연수구 송도동', + operator: '한국가스공사(KOGAS)', + description: '세계 최대 규모의 해상 LNG 기지 중 하나.', + }, + { + id: 'lng-03', type: 'lng', + nameKo: '통영 LNG 생산기지', name: 'Tongyeong LNG Production Base', + lat: 34.906, lng: 128.465, + address: '경상남도 통영시 광도면', + operator: '한국가스공사(KOGAS)', + description: '남부권 가스 공급 및 영남권 산업단지 지원 거점.', + }, + { + id: 'lng-04', type: 'lng', + nameKo: '삼척 LNG 생산기지', name: 'Samcheok LNG Production Base', + lat: 37.262, lng: 129.290, + address: '강원도 삼척시 원덕읍', + operator: '한국가스공사(KOGAS)', + description: '동해안 에너지 거점 및 수입 다변화 대응.', + }, + { + id: 'lng-05', type: 'lng', + nameKo: '제주 LNG 생산기지', name: 'Jeju LNG Production Base', + lat: 33.448, lng: 126.330, + address: '제주특별자치도 제주시 애월읍', + operator: '한국가스공사(KOGAS)', + description: '제주 지역 천연가스 보급을 위해 조성된 기지.', + }, + { + id: 'lng-06', type: 'lng', + nameKo: '당진 LNG 생산기지', name: 'Dangjin LNG Production Base', + lat: 37.048, lng: 126.595, + address: '충청남도 당진시 석문면', + operator: '한국가스공사(KOGAS)', + description: '2026년 말 1단계 준공 예정 (현재 건설 중).', + }, + + // ── 민간 LNG 터미널 ────────────────────────────────────────────── + { + id: 'lng-p01', type: 'lng', + nameKo: '광양 LNG 터미널', name: 'Gwangyang LNG Terminal', + lat: 34.934, lng: 127.714, + address: '전라남도 광양시 금호동', + operator: '포스코인터내셔널', + description: '포스코인터내셔널 운영 민간 LNG 터미널.', + }, + { + id: 'lng-p02', type: 'lng', + nameKo: '보령 LNG 터미널', name: 'Boryeong LNG Terminal', + lat: 36.380, lng: 126.513, + address: '충청남도 보령시 오천면', + operator: 'SK E&S · GS에너지', + description: 'SK E&S·GS에너지 공동 운영 민간 LNG 터미널.', + }, + { + id: 'lng-p03', type: 'lng', + nameKo: '울산 북항 에너지터미널', name: 'Ulsan North Port Energy Terminal', + lat: 35.518, lng: 129.383, + address: '울산광역시 남구 북항 일원', + operator: 'KET (한국석유공사·SK Gas 등)', + description: 'KET(Korea Energy Terminal) 운영 민간 에너지터미널.', + }, + { + id: 'lng-p04', type: 'lng', + nameKo: '통영 에코파워 LNG', name: 'Tongyeong Ecopower LNG Terminal', + lat: 34.873, lng: 128.508, + address: '경상남도 통영시 광도면 (성동조선 인근)', + operator: 'HDC현대산업개발 등', + description: '성동조선 인근 민간 LNG 터미널.', + }, + + // ── 유류저장탱크 ────────────────────────────────────────────────── + { + id: 'oil-01', type: 'oilTank', + nameKo: '여수 유류저장시설', name: 'Yeosu Oil Storage', + lat: 34.733, lng: 127.741, + capacity: '630만 ㎘', operator: 'SK에너지·GS칼텍스', + description: '여수항 인근 정유제품 및 원유 저장시설.', + }, + { + id: 'oil-02', type: 'oilTank', + nameKo: '울산 정유 저장시설', name: 'Ulsan Refinery Storage', + lat: 35.516, lng: 129.413, + capacity: '850만 ㎘', operator: 'S-OIL·SK에너지', + description: '울산 온산 정유시설 연계 대형 유류탱크군.', + }, + { + id: 'oil-03', type: 'oilTank', + nameKo: '포항 저유소', name: 'Pohang Oil Depot', + lat: 36.018, lng: 129.380, + capacity: '20만 ㎘', operator: '대한송유관공사', + description: '동해안 석유 공급 거점 저유소.', + }, + { + id: 'oil-04', type: 'oilTank', + nameKo: '목포 유류저장', name: 'Mokpo Oil Storage', + lat: 34.773, lng: 126.384, + capacity: '30만 ㎘', operator: '한국석유공사', + description: '서남해안 유류 공급 저장기지.', + }, + { + id: 'oil-05', type: 'oilTank', + nameKo: '부산 북항 저유소', name: 'Busan North Port Oil Depot', + lat: 35.100, lng: 129.041, + capacity: '45만 ㎘', operator: '대한송유관공사', + description: '부산항 연계 유류 저장·공급 시설.', + }, + { + id: 'oil-06', type: 'oilTank', + nameKo: '보령 저유소', name: 'Boryeong Oil Depot', + lat: 36.380, lng: 126.570, + capacity: '15만 ㎘', operator: '대한송유관공사', + description: '충남 서해안 유류 공급 저장기지.', + }, + + // ── KNOC 국가 석유비축기지 ──────────────────────────────────────── + { + id: 'knoc-01', type: 'oilTank', + nameKo: 'KNOC 울산 비축기지', name: 'KNOC Ulsan SPR Base', + lat: 35.406, lng: 129.351, + address: '울산광역시 울주군 온산읍 학남리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지상탱크) 방식.', + }, + { + id: 'knoc-02', type: 'oilTank', + nameKo: 'KNOC 여수 비축기지', name: 'KNOC Yeosu SPR Base', + lat: 34.716, lng: 127.742, + address: '전라남도 여수시 낙포동', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지상탱크·지하공동) 방식.', + }, + { + id: 'knoc-03', type: 'oilTank', + nameKo: 'KNOC 거제 비축기지', name: 'KNOC Geoje SPR Base', + lat: 34.852, lng: 128.722, + address: '경상남도 거제시 일운면 지세포리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지하공동) 방식.', + }, + { + id: 'knoc-04', type: 'oilTank', + nameKo: 'KNOC 서산 비축기지', name: 'KNOC Seosan SPR Base', + lat: 37.018, lng: 126.374, + address: '충청남도 서산시 대산읍 대죽리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유·제품 (지상탱크) 방식.', + }, + { + id: 'knoc-05', type: 'oilTank', + nameKo: 'KNOC 평택 비축기지', name: 'KNOC Pyeongtaek SPR Base', + lat: 37.017, lng: 126.858, + address: '경기도 평택시 포승읍 원정리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. LPG 방식.', + }, + { + id: 'knoc-06', type: 'oilTank', + nameKo: 'KNOC 구리 비축기지', name: 'KNOC Guri SPR Base', + lat: 37.562, lng: 127.138, + address: '경기도 구리시 아차산로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지하공동) 방식.', + }, + { + id: 'knoc-07', type: 'oilTank', + nameKo: 'KNOC 용인 비축기지', name: 'KNOC Yongin SPR Base', + lat: 37.238, lng: 127.213, + address: '경기도 용인시 처인구 해실로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + { + id: 'knoc-08', type: 'oilTank', + nameKo: 'KNOC 동해 비축기지', name: 'KNOC Donghae SPR Base', + lat: 37.503, lng: 129.097, + address: '강원특별자치도 동해시 공단12로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + { + id: 'knoc-09', type: 'oilTank', + nameKo: 'KNOC 곡성 비축기지', name: 'KNOC Gokseong SPR Base', + lat: 35.228, lng: 127.302, + address: '전라남도 곡성군 겸면 괴정리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + + // ── 위험물항만하역시설 ──────────────────────────────────────────── + { + id: 'hp-01', type: 'hazardPort', + nameKo: '광양항 위험물 부두', name: 'Gwangyang Hazardous Cargo Terminal', + lat: 34.923, lng: 127.703, + capacity: '연 3,000만 톤', operator: '여수광양항만공사', + description: '석유화학제품·액체화물 전용 위험물 하역 부두.', + }, + { + id: 'hp-02', type: 'hazardPort', + nameKo: '울산항 위험물 부두', name: 'Ulsan Hazardous Cargo Terminal', + lat: 35.519, lng: 129.392, + capacity: '연 2,500만 톤', operator: '울산항만공사', + description: '원유·석유제품·LPG 등 위험물 전용 하역 부두.', + }, + { + id: 'hp-03', type: 'hazardPort', + nameKo: '인천항 위험물 부두', name: 'Incheon Hazardous Cargo Terminal', + lat: 37.464, lng: 126.621, + capacity: '연 800만 톤', operator: '인천항만공사', + description: '인천 북항 위험물(화학·가스·유류) 하역 전용 부두.', + }, + { + id: 'hp-04', type: 'hazardPort', + nameKo: '여수항 위험물 부두', name: 'Yeosu Hazardous Cargo Terminal', + lat: 34.729, lng: 127.741, + capacity: '연 1,200만 톤', operator: '여수광양항만공사', + description: '여수 석유화학단지 연계 위험물 하역 부두.', + }, + { + id: 'hp-05', type: 'hazardPort', + nameKo: '부산항 위험물 부두', name: 'Busan Hazardous Cargo Terminal', + lat: 35.090, lng: 129.022, + capacity: '연 500만 톤', operator: '부산항만공사', + description: '부산 신항·북항 위험물 전용 하역 부두.', + }, + { + id: 'hp-06', type: 'hazardPort', + nameKo: '군산항 위험물 부두', name: 'Gunsan Hazardous Cargo Terminal', + lat: 35.973, lng: 126.712, + capacity: '연 300만 톤', operator: '군산항만공사', + description: '서해안 위험물(석유·화학) 하역 부두.', + }, + + // ── 원자력발전소 ────────────────────────────────────────────────── + { + id: 'npp-01', type: 'nuclear', + nameKo: '고리 원자력발전소', name: 'Kori Nuclear Power Plant', + lat: 35.316, lng: 129.291, + address: '부산광역시 기장군 장안읍 고리', + capacity: '4기 (신고리 포함 총 6기)', operator: '한국수력원자력(한수원)', + description: '국내 최초 상업용 원전 부지. 1호기 영구정지(2017), 신고리 1~4호기 운영 중.', + }, + { + id: 'npp-02', type: 'nuclear', + nameKo: '월성 원자력발전소', name: 'Wolseong Nuclear Power Plant', + lat: 35.712, lng: 129.476, + address: '경상북도 경주시 양남면 나아리', + capacity: '4기 (월성·신월성)', operator: '한국수력원자력(한수원)', + description: '중수로(CANDU) 방식. 월성 1호기 영구정지(2019), 신월성 1·2호기 운영 중.', + }, + { + id: 'npp-03', type: 'nuclear', + nameKo: '한울 원자력발전소', name: 'Hanul Nuclear Power Plant', + lat: 37.093, lng: 129.381, + address: '경상북도 울진군 북면 부구리', + capacity: '6기 운영 + 신한울 2기', operator: '한국수력원자력(한수원)', + description: '구 울진 원전. 한울 1~6호기 + 신한울 1·2호기(2022~2024 준공).', + }, + { + id: 'npp-04', type: 'nuclear', + nameKo: '한빛 원자력발전소', name: 'Hanbit Nuclear Power Plant', + lat: 35.410, lng: 126.424, + address: '전라남도 영광군 홍농읍 계마리', + capacity: '6기 운영', operator: '한국수력원자력(한수원)', + description: '구 영광 원전. 한빛 1~6호기 운영 중. 국내 최대 용량 원전 부지.', + }, + { + id: 'npp-05', type: 'nuclear', + nameKo: '새울 원자력발전소', name: 'Saeul Nuclear Power Plant', + lat: 35.311, lng: 129.303, + address: '울산광역시 울주군 서생면 신암리', + capacity: '4기 (신고리 5~8호기)', operator: '한국수력원자력(한수원)', + description: '신고리 5·6호기 운영 중, 7·8호기 건설 예정. 고리 부지 인근.', + }, + + // ── 화력발전소 ──────────────────────────────────────────────────── + { + id: 'tp-01', type: 'thermal', + nameKo: '당진 화력발전소', name: 'Dangjin Thermal Power Plant', + lat: 37.048, lng: 126.598, + address: '충청남도 당진시 석문면 교로리', + capacity: '6,040MW (10기)', operator: '한국동서발전(EWP)', + description: '국내 최대 규모 석탄 화력발전소.', + }, + { + id: 'tp-02', type: 'thermal', + nameKo: '태안 화력발전소', name: 'Taean Thermal Power Plant', + lat: 36.849, lng: 126.232, + address: '충청남도 태안군 원북면 방갈리', + capacity: '6,100MW (10기)', operator: '한국서부발전(WPP)', + description: '서해안 최대 규모 석탄 화력발전소.', + }, + { + id: 'tp-03', type: 'thermal', + nameKo: '삼척 화력발전소', name: 'Samcheok Thermal Power Plant', + lat: 37.243, lng: 129.326, + address: '강원특별자치도 삼척시 근덕면 초곡리', + capacity: '2,100MW (2기)', operator: '삼척블루파워(포스코에너지·GS에너지)', + description: '동해안 민자 석탄 화력발전소. 2022년 준공.', + }, + { + id: 'tp-04', type: 'thermal', + nameKo: '여수 화력발전소', name: 'Yeosu Thermal Power Plant', + lat: 34.738, lng: 127.721, + address: '전라남도 여수시 낙포동', + capacity: '870MW', operator: 'GS E&R', + description: '여수 석유화학단지 인근 열병합 발전소.', + }, + { + id: 'tp-05', type: 'thermal', + nameKo: '하동 화력발전소', name: 'Hadong Thermal Power Plant', + lat: 34.977, lng: 127.901, + address: '경상남도 하동군 금성면 갈사리', + capacity: '4,000MW (8기)', operator: '한국남부발전(KOSPO)', + description: '남해안 주요 석탄 화력발전소.', + }, + + // ── 조선소 도장시설 ─────────────────────────────────────────────── + { + id: 'sy-01', type: 'shipyard', + nameKo: '한화오션 거제조선소', name: 'Hanwha Ocean Geoje Shipyard', + lat: 34.893, lng: 128.623, + address: '경상남도 거제시 아주동 1', + operator: '한화오션(구 대우조선해양)', + description: '초대형 선박·해양플랜트 도장시설. 유기용제·VOC 대량 취급.', + }, + { + id: 'sy-02', type: 'shipyard', + nameKo: 'HD현대중공업 울산조선소', name: 'HD Hyundai Heavy Industries Ulsan Shipyard', + lat: 35.508, lng: 129.421, + address: '울산광역시 동구 방어진순환도로 1000', + operator: 'HD현대중공업', + description: '세계 최대 단일 조선소. 도크 10기, 도장시설·VOC 취급.', + }, + { + id: 'sy-03', type: 'shipyard', + nameKo: '삼성중공업 거제조선소', name: 'Samsung Heavy Industries Geoje Shipyard', + lat: 34.847, lng: 128.682, + address: '경상남도 거제시 장평동 530', + operator: '삼성중공업', + description: 'LNG 운반선·FPSO 전문 조선소. 도장·도막 처리시설.', + }, + { + id: 'sy-04', type: 'shipyard', + nameKo: 'HD현대미포조선 울산', name: 'HD Hyundai Mipo Dockyard Ulsan', + lat: 35.479, lng: 129.407, + address: '울산광역시 동구 화정동', + operator: 'HD현대미포조선', + description: '중형 선박 전문 조선소. 도장시설 다수.', + }, + { + id: 'sy-05', type: 'shipyard', + nameKo: 'HD현대삼호 영암조선소', name: 'HD Hyundai Samho Yeongam Shipyard', + lat: 34.746, lng: 126.459, + address: '전라남도 영암군 삼호읍 용당리', + operator: 'HD현대삼호중공업', + description: '서남해안 대형 조선소. 유기용제·도장 화학물질 취급.', + }, + { + id: 'sy-06', type: 'shipyard', + nameKo: 'HJ중공업 부산조선소', name: 'HJ Shipbuilding Busan Shipyard', + lat: 35.048, lng: 128.978, + address: '부산광역시 영도구 해양로 195', + operator: 'HJ중공업(구 한진중공업)', + description: '부산 영도 소재 조선소. 도장·표면처리 시설.', + }, + + // ── 폐수/하수처리장 ─────────────────────────────────────────────── + { + id: 'ww-01', type: 'wastewater', + nameKo: '여수 국가산단 폐수처리장', name: 'Yeosu Industrial Wastewater Treatment', + lat: 34.748, lng: 127.730, + address: '전라남도 여수시 화치동', + operator: '여수시·환경부', + description: '여수국가산단 배후 산업폐수처리장. 황화수소·메탄 발생 가능.', + }, + { + id: 'ww-02', type: 'wastewater', + nameKo: '울산 온산공단 폐수처리장', name: 'Ulsan Onsan Industrial Wastewater Treatment', + lat: 35.413, lng: 129.338, + address: '울산광역시 울주군 온산읍', + operator: '울산시·환경부', + description: '온산국가산업단지 배후 폐수처리 거점. 유해가스 발생 위험.', + }, + { + id: 'ww-03', type: 'wastewater', + nameKo: '대산공단 폐수처리장', name: 'Daesan Industrial Wastewater Treatment', + lat: 37.023, lng: 126.348, + address: '충청남도 서산시 대산읍', + operator: '서산시·환경부', + description: '대산석유화학단지 배후 폐수처리장. H₂S·메탄 발생 위험.', + }, + { + id: 'ww-04', type: 'wastewater', + nameKo: '인천 북항 항만폐수처리', name: 'Incheon North Port Wastewater Treatment', + lat: 37.468, lng: 126.618, + address: '인천광역시 중구 북성동', + operator: '인천항만공사·인천시', + description: '인천 북항 인접 항만 폐수처리 시설.', + }, + { + id: 'ww-05', type: 'wastewater', + nameKo: '광양 임해 폐수처리장', name: 'Gwangyang Coastal Wastewater Treatment', + lat: 34.930, lng: 127.696, + address: '전라남도 광양시 금호동', + operator: '광양시·포스코', + description: '광양제철소·산단 배후 폐수처리 시설. 황화수소 발생 위험.', + }, + + // ── 시멘트/제철소/원료저장시설 ──────────────────────────────────── + { + id: 'hi-01', type: 'heavyIndustry', + nameKo: 'POSCO 포항제철소', name: 'POSCO Pohang Steelworks', + lat: 36.027, lng: 129.358, + address: '경상북도 포항시 남구 동해안로 6261', + capacity: '1,800만 톤/년', operator: 'POSCO', + description: '국내 최대 제철소. 고로·코크스 원료 대량 저장·처리.', + }, + { + id: 'hi-02', type: 'heavyIndustry', + nameKo: 'POSCO 광양제철소', name: 'POSCO Gwangyang Steelworks', + lat: 34.932, lng: 127.702, + address: '전라남도 광양시 금호동 700', + capacity: '2,100만 톤/년', operator: 'POSCO', + description: '세계 최대 규모 제철소 중 하나. 임해 원료 저장기지.', + }, + { + id: 'hi-03', type: 'heavyIndustry', + nameKo: '현대제철 당진공장', name: 'Hyundai Steel Dangjin Plant', + lat: 37.046, lng: 126.616, + address: '충청남도 당진시 송악읍 복운리', + capacity: '1,200만 톤/년', operator: '현대제철', + description: '당진 임해 제철소. 철광석·석탄 원료저장 부두 인접.', + }, + { + id: 'hi-04', type: 'heavyIndustry', + nameKo: '삼척 시멘트 공단', name: 'Samcheok Cement Industrial Complex', + lat: 37.480, lng: 129.130, + address: '강원특별자치도 삼척시 동해대로', + operator: '쌍용C&E·성신양회', + description: '삼척 임해 시멘트 단지. 분진·원료저장시설 밀집.', + }, + { + id: 'hi-05', type: 'heavyIndustry', + nameKo: '동해 시멘트/석회공장', name: 'Donghae Cement Complex', + lat: 37.501, lng: 129.103, + address: '강원특별자치도 동해시 북평공단', + operator: '한일시멘트·아세아시멘트', + description: '동해항 인근 시멘트·석회 생산·원료저장시설.', + }, +]; diff --git a/frontend/src/data/jpFacilities.ts b/frontend/src/data/jpFacilities.ts new file mode 100644 index 0000000..e7d4f60 --- /dev/null +++ b/frontend/src/data/jpFacilities.ts @@ -0,0 +1,150 @@ +export interface JpFacility { + id: string; + name: string; + subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army'; + lat: number; + lng: number; + operator?: string; + description?: string; +} + +export const JP_POWER_PLANTS: JpFacility[] = [ + { + id: 'jp-npp-genkai', + name: '겐카이(玄海) 핵발전소', + subType: 'nuclear', + lat: 33.52, + lng: 129.84, + operator: '규슈전력', + description: '가압경수로 4기, 사가현 — 한반도 최근접 원전', + }, + { + id: 'jp-npp-sendai', + name: '센다이(川内) 핵발전소', + subType: 'nuclear', + lat: 31.84, + lng: 130.19, + operator: '규슈전력', + description: '가압경수로 2기, 가고시마현', + }, + { + id: 'jp-npp-ohi', + name: '오이(大飯) 핵발전소', + subType: 'nuclear', + lat: 35.53, + lng: 135.65, + operator: '간사이전력', + description: '가압경수로 4기, 후쿠이현 — 일본 최대 출력', + }, + { + id: 'jp-npp-takahama', + name: '다카하마(高浜) 핵발전소', + subType: 'nuclear', + lat: 35.51, + lng: 135.50, + operator: '간사이전력', + description: '가압경수로 4기, 후쿠이현', + }, + { + id: 'jp-npp-shika', + name: '시카(志賀) 핵발전소', + subType: 'nuclear', + lat: 37.07, + lng: 136.72, + operator: '호쿠리쿠전력', + description: '비등수형경수로 2기, 이시카와현 (2024 지진 피해)', + }, + { + id: 'jp-npp-higashidori', + name: '히가시도리(東通) 핵발전소', + subType: 'nuclear', + lat: 41.18, + lng: 141.37, + operator: '도호쿠전력', + description: '비등수형경수로, 아오모리현', + }, + { + id: 'jp-thermal-matsuura', + name: '마쓰우라(松浦) 화력발전소', + subType: 'thermal', + lat: 33.33, + lng: 129.73, + operator: '전원개발(J-Power)', + description: '석탄화력, 나가사키현 — 대한해협 인접', + }, + { + id: 'jp-thermal-hekinan', + name: '헤키난(碧南) 화력발전소', + subType: 'thermal', + lat: 34.87, + lng: 136.95, + operator: '주부전력', + description: '석탄화력, 아이치현 — 일본 최대 석탄화력', + }, +]; + +export const JP_MILITARY_FACILITIES: JpFacility[] = [ + { + id: 'jp-mil-sasebo', + name: '사세보(佐世保) 해군기지', + subType: 'naval', + lat: 33.16, + lng: 129.72, + operator: '미 해군 / 해상자위대', + description: '미 7함대 상륙전단 모항, 한국 최근접 미군기지', + }, + { + id: 'jp-mil-maizuru', + name: '마이즈루(舞鶴) 해군기지', + subType: 'naval', + lat: 35.47, + lng: 135.38, + operator: '해상자위대', + description: '동해 방면 주력기지, 호위함대 사령부', + }, + { + id: 'jp-mil-yokosuka', + name: '요코스카(横須賀) 해군기지', + subType: 'naval', + lat: 35.29, + lng: 139.67, + operator: '미 해군 / 해상자위대', + description: '미 7함대 사령부, 항모 로널드 레이건 모항', + }, + { + id: 'jp-mil-iwakuni', + name: '이와쿠니(岩国) 공군기지', + subType: 'airbase', + lat: 34.15, + lng: 132.24, + operator: '미 해병대 / 항공자위대', + description: 'F/A-18 및 F-35B 배치, 야마구치현', + }, + { + id: 'jp-mil-kadena', + name: '가데나(嘉手納) 공군기지', + subType: 'airbase', + lat: 26.36, + lng: 127.77, + operator: '미 공군', + description: 'F-15C/D, KC-135 배치, 아시아 최대 미 공군기지', + }, + { + id: 'jp-mil-ashiya', + name: '아시야(芦屋) 항공기지', + subType: 'airbase', + lat: 33.88, + lng: 130.66, + operator: '항공자위대', + description: '대한해협 인접, 후쿠오카현', + }, + { + id: 'jp-mil-naha', + name: '나하(那覇) 항공기지', + subType: 'airbase', + lat: 26.21, + lng: 127.65, + operator: '항공자위대', + description: 'F-15 배치, 남서항공방면대 사령부', + }, +]; diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 7907371..7a9a6fe 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -25,6 +25,12 @@ import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWa import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy'; import type { PiracyZone } from '../services/piracy'; import type { PowerFacility } from '../services/infra'; +import { HAZARD_FACILITIES } from '../data/hazardFacilities'; +import type { HazardFacility, HazardType } from '../data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../data/cnFacilities'; +import type { CnFacility } from '../data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../data/jpFacilities'; +import type { JpFacility } from '../data/jpFacilities'; // ─── Type alias to avoid 'any' in PickingInfo ─────────────────────────────── @@ -39,7 +45,10 @@ export type StaticPickedObject = | KoreanAirport | NavWarning | PiracyZone - | PowerFacility; + | PowerFacility + | HazardFacility + | CnFacility + | JpFacility; export type StaticLayerKind = | 'port' @@ -52,7 +61,10 @@ export type StaticLayerKind = | 'airport' | 'navWarning' | 'piracy' - | 'infra'; + | 'infra' + | 'hazard' + | 'cnFacility' + | 'jpFacility'; export interface StaticPickInfo { kind: StaticLayerKind; @@ -72,6 +84,11 @@ interface StaticLayerConfig { piracy: boolean; infra: boolean; infraFacilities: PowerFacility[]; + hazardTypes: HazardType[]; + cnPower: boolean; + cnMilitary: boolean; + jpPower: boolean; + jpMilitary: boolean; onPick: (info: StaticPickInfo) => void; sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0) } @@ -866,6 +883,176 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { ); } + // ── Hazard Facilities ────────────────────────────────────────────────── + if (config.hazardTypes.length > 0) { + const hazardTypeSet = new Set(config.hazardTypes); + const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); + + const HAZARD_META: Record = { + petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, + lng: { icon: '🔵', color: [6, 182, 212, 255] }, + oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, + hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, + nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, + thermal: { icon: '🔥', color: [100, 116, 139, 255] }, + shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, + wastewater: { icon: '💧', color: [16, 185, 129, 255] }, + heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, + }; + + if (hazardData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-hazard-emoji', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', + getSize: 16 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'hazard', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-hazard-label', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 9 * ss, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── CN Facilities ────────────────────────────────────────────────────── + { + const CN_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, + }; + const cnData: CnFacility[] = [ + ...(config.cnPower ? CN_POWER_PLANTS : []), + ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), + ]; + if (cnData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-cn-emoji', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => CN_META[d.subType]?.icon ?? '📍', + getSize: 16 * ss, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'cnFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-cn-label', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * ss, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── JP Facilities ────────────────────────────────────────────────────── + { + const JP_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + }; + const jpData: JpFacility[] = [ + ...(config.jpPower ? JP_POWER_PLANTS : []), + ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), + ]; + if (jpData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-jp-emoji', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => JP_META[d.subType]?.icon ?? '📍', + getSize: 16 * ss, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'jpFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-jp-label', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * ss, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + return layers; // infraFacilities는 배열 참조가 바뀌어야 갱신 // eslint-disable-next-line react-hooks/exhaustive-deps @@ -882,6 +1069,11 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.nkMissile, config.infra, config.infraFacilities, + config.hazardTypes, + config.cnPower, + config.cnMilitary, + config.jpPower, + config.jpMilitary, config.onPick, config.sizeScale, ]); @@ -889,5 +1081,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { // Re-export types that KoreaMap will need for Popup rendering export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility }; +export type { HazardFacility, HazardType, CnFacility, JpFacility }; // Re-export label/color helpers used in Popup rendering export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor }; diff --git a/frontend/src/i18n/locales/ko/common.json b/frontend/src/i18n/locales/ko/common.json index a20e3f4..39a8287 100644 --- a/frontend/src/i18n/locales/ko/common.json +++ b/frontend/src/i18n/locales/ko/common.json @@ -77,7 +77,7 @@ "airports": "공항", "sensorCharts": "센서 차트", "oilFacilities": "유전시설", - "militaryOnly": "군용기만", + "militaryOnly": "해외시설", "infra": "발전/변전", "cables": "해저케이블", "cctv": "CCTV", diff --git a/frontend/src/services/disasterNews.ts b/frontend/src/services/disasterNews.ts new file mode 100644 index 0000000..b3e15db --- /dev/null +++ b/frontend/src/services/disasterNews.ts @@ -0,0 +1,146 @@ +// 재난/안전뉴스 — 국가재난안전포털(safekorea.go.kr) 뉴스 +// CORS 제한으로 직접 크롤링 불가 → 큐레이션된 최신 항목 + 포털 링크 제공 + +export interface DisasterNewsItem { + id: string; + timestamp: number; + title: string; + source: string; + category: 'typhoon' | 'flood' | 'earthquake' | 'fire' | 'sea' | 'chemical' | 'safety' | 'general'; + url: string; +} + +const SAFEKOREA_BASE = 'https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619'; + +const CAT_ICON: Record = { + typhoon: '🌀', + flood: '🌊', + earthquake: '⚡', + fire: '🔥', + sea: '⚓', + chemical: '☣️', + safety: '🦺', + general: '📢', +}; + +const CAT_COLOR: Record = { + typhoon: '#06b6d4', + flood: '#3b82f6', + earthquake: '#f59e0b', + fire: '#ef4444', + sea: '#0ea5e9', + chemical: '#a855f7', + safety: '#22c55e', + general: '#64748b', +}; + +export function getDisasterCatIcon(cat: DisasterNewsItem['category']) { + return CAT_ICON[cat] ?? CAT_ICON.general; +} +export function getDisasterCatColor(cat: DisasterNewsItem['category']) { + return CAT_COLOR[cat] ?? CAT_COLOR.general; +} + +// ── 큐레이션된 최신 재난/안전뉴스 (2026-03-21 기준) ────────────────── +export const DISASTER_NEWS: DisasterNewsItem[] = [ + { + id: 'dn-0321-01', + timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(), + title: '[행안부] 봄철 해양레저 안전 유의… 3월~5월 수상사고 집중 발생 시기', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0321-02', + timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(), + title: '해경, 갯벌 고립사고 주의 당부… 조석표 미확인 갯벌체험 사망 증가', + source: '해양경찰청', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0320-01', + timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(), + title: '부산 강서구 화학공장 화재… 유독가스 유출, 인근 주민 대피령 (완진)', + source: '국가재난안전포털', + category: 'chemical', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0320-02', + timestamp: new Date('2026-03-20T10:00:00+09:00').getTime(), + title: '[기상청] 서해상 강풍 예비특보 발효… 최대 순간풍속 25m/s 예상', + source: '기상청', + category: 'general', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0319-01', + timestamp: new Date('2026-03-19T14:00:00+09:00').getTime(), + title: '여수 앞바다 어선 전복… 선원 5명 중 3명 구조, 2명 수색 중', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0319-02', + timestamp: new Date('2026-03-19T09:00:00+09:00').getTime(), + title: '행안부, 봄철 산불 위기경보 "주의" 발령… 강원·경북 건조특보 지속', + source: '행정안전부', + category: 'fire', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0318-01', + timestamp: new Date('2026-03-18T11:00:00+09:00').getTime(), + title: '경주 규모 2.8 지진 발생… 인근 원전 이상 없음, 여진 주의', + source: '기상청', + category: 'earthquake', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0318-02', + timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(), + title: '울산 온산공단 배관 누출… 황화수소 소량 유출, 인근 학교 임시 휴교', + source: '국가재난안전포털', + category: 'chemical', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0317-01', + timestamp: new Date('2026-03-17T15:00:00+09:00').getTime(), + title: '포항 해상 화물선 기관실 화재… 해경 대응, 선원 전원 구조', + source: '해양경찰청', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0317-02', + timestamp: new Date('2026-03-17T10:00:00+09:00').getTime(), + title: '[소방청] 봄철 소방안전대책 시행… 주거용 소화기 무상 교체 4월까지 연장', + source: '소방청', + category: 'safety', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0316-01', + timestamp: new Date('2026-03-16T13:00:00+09:00').getTime(), + title: '태안 앞바다 유류 오염 사고… 어선 충돌로 벙커C유 3톤 유출, 방제 작업 중', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0316-02', + timestamp: new Date('2026-03-16T09:00:00+09:00').getTime(), + title: '행안부, 이란 사태 관련 국내 핵심기반시설 특별점검 실시', + source: '행정안전부', + category: 'safety', + url: SAFEKOREA_BASE, + }, +]; + +export function getDisasterNews(): DisasterNewsItem[] { + return DISASTER_NEWS.sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index 16d05f2..1304c45 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -243,7 +243,67 @@ function extractMELocation(text: string): { lat: number; lng: number } | null { // ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ── // Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리 const CENTCOM_POSTS: { text: string; date: string; url: string }[] = [ - // ── 3월 16일 (D+16) 최신 ── + // ── 3월 21일 (D+21) 최신 ── + { + text: 'CENTCOM: US-Iran ceasefire negotiations in Muscat enter Day 2. CENTCOM forces maintaining "minimal operations" posture pending diplomatic outcome', + date: '2026-03-21T06:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'UPDATE: Strait of Hormuz commercial traffic restored to 72% of pre-conflict levels. 23 tankers transited safely in past 24hrs under coalition escort', + date: '2026-03-21T02:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 20일 (D+20) ── + { + text: 'CENTCOM: US and Iranian delegations meet in Muscat, Oman for preliminary ceasefire talks. Omani FM Al-Busaidi mediating. No agreement yet but "atmosphere constructive"', + date: '2026-03-20T14:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'Brent crude falls to $97/barrel on ceasefire talk optimism — first time below $100 since Operation Epic Fury began', + date: '2026-03-20T08:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'Multiple senior IRGC commanders reported to have departed Iran for Russia. CENTCOM assesses Iran\'s strategic command continuity "severely degraded"', + date: '2026-03-20T04:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 19일 (D+19) ── + { + text: 'BREAKING: Iran signals readiness for "unconditional ceasefire talks" through Oman channel. CENTCOM suspends offensive air operations pending diplomatic contact', + date: '2026-03-19T18:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM: Strait of Hormuz now 60% restored to normal commercial traffic. Coalition minesweeping teams cleared 41 mines total since Day 1', + date: '2026-03-19T09:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 18일 (D+18) ── + { + text: 'CENTCOM: Houthi forces launched coordinated mini-submarine torpedo attack against USS Nimitz CSG in Red Sea. All 3 vessels intercepted and destroyed', + date: '2026-03-18T20:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM: F-22 Raptors conducted first-ever combat operations over Iranian airspace, escorting B-2s striking hardened underground sites near Qom', + date: '2026-03-18T07:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 17일 (D+17) ── + { + text: 'CENTCOM: B-2 stealth bombers and GBU-57 MOPs successfully struck the Fordow Fuel Enrichment Plant. Underground enrichment halls confirmed destroyed', + date: '2026-03-17T10:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'BREAKING: Iran\'s new Supreme Leader Mojtaba Khamenei issues statement delegating "pre-authorized nuclear retaliation" to IRGC. UN Security Council convenes emergency session', + date: '2026-03-17T05:30:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 16일 (D+16) ── { text: 'CENTCOM: Isfahan military complex struck overnight by B-2 stealth bombers. 15 targets destroyed including underground command bunkers', date: '2026-03-16T06:00:00Z', @@ -404,7 +464,132 @@ async function fetchXCentcom(): Promise { // ── Pinned OSINT articles (manually curated) ── const PINNED_IRAN: OsintItem[] = [ - // ── 3월 16일 최신 ── + // ── 3월 21일 최신 ── + { + id: 'pinned-kr-ceasefire-talks-0321', + timestamp: new Date('2026-03-21T10:00:00+09:00').getTime(), + title: '[속보] 미-이란, 오만 무스카트서 휴전 협상 2일차… "핵 시설 감시단 수용" 이란 내부 검토', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 23.58, lng: 58.40, + }, + { + id: 'pinned-kr-oil-drop-0321', + timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(), + title: '브렌트유 $97로 하락… 휴전 협상 기대감 반영, 한국 정유사 비축유 방출 중단 검토', + source: '매일경제', + url: 'https://www.mk.co.kr', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + { + id: 'pinned-kr-hormuz-72pct-0321', + timestamp: new Date('2026-03-21T06:00:00+09:00').getTime(), + title: '호르무즈 해협 통항량 72% 회복… 한국 수입 유조선 5척 오늘 무사 통과', + source: 'SBS', + url: 'https://news.sbs.co.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 20일 ── + { + id: 'pinned-kr-muscat-talks-0320', + timestamp: new Date('2026-03-20T20:00:00+09:00').getTime(), + title: '[긴급] 미-이란 협상단 오만 무스카트 회동 확인… 오만 외무 중재, 핵 동결 조건 논의', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 23.58, lng: 58.40, + }, + { + id: 'pinned-kr-irgc-flee-0320', + timestamp: new Date('2026-03-20T14:00:00+09:00').getTime(), + title: 'IRGC 고위 사령관 다수, 러시아 망명 정황 포착… 이란 지휘체계 붕괴 우려', + source: '조선일보', + url: 'https://www.chosun.com', + category: 'military', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + { + id: 'pinned-kr-tanker-return-0320', + timestamp: new Date('2026-03-20T09:00:00+09:00').getTime(), + title: '한국 유조선 "광양 파이오니어호" 호르무즈 통과 성공… 30일 만에 첫 정상 귀항', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 19일 ── + { + id: 'pinned-kr-iran-ceasefire-0319', + timestamp: new Date('2026-03-19T18:00:00+09:00').getTime(), + title: '[속보] 이란, 오만 채널 통해 "무조건 휴전 협상 준비" 신호… 미국 "확인 중"', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + { + id: 'pinned-kr-ko-reserves-0319', + timestamp: new Date('2026-03-19T12:00:00+09:00').getTime(), + title: '정부 "원유 수급 숨통 트였다"… 비축유 80일분 유지·추가 방출 잠정 보류', + source: '서울경제', + url: 'https://en.sedaily.com', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + // ── 3월 18일 ── + { + id: 'pinned-kr-houthi-sub-0318', + timestamp: new Date('2026-03-18T22:00:00+09:00').getTime(), + title: '예멘 후티, 미 항공모함 겨냥 소형 잠수정 어뢰 공격 시도… 미 해군 3척 격침', + source: 'BBC Korea', + url: 'https://www.bbc.com/korean', + category: 'military', + language: 'ko', + lat: 14.80, lng: 42.95, + }, + { + id: 'pinned-kr-f22-0318', + timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(), + title: 'F-22 랩터, 이란 상공 첫 실전 투입 확인… B-2 호위하며 쿰 인근 지하시설 공격', + source: '조선일보', + url: 'https://www.chosun.com', + category: 'military', + language: 'ko', + lat: 34.64, lng: 50.88, + }, + // ── 3월 17일 ── + { + id: 'pinned-kr-fordow-0317', + timestamp: new Date('2026-03-17T12:00:00+09:00').getTime(), + title: '[속보] 미군, 포르도 핵연료 농축시설 벙커버스터 공격… 지하 격납고 완파 확인', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'nuclear', + language: 'ko', + lat: 34.88, lng: 49.93, + }, + { + id: 'pinned-kr-nuclear-threat-0317', + timestamp: new Date('2026-03-17T07:00:00+09:00').getTime(), + title: '이란 최고지도자, IRGC에 "선제 핵 보복 권한 위임" 발표… UN 안보리 긴급 소집', + source: 'MBC', + url: 'https://imnews.imbc.com', + category: 'nuclear', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + // ── 3월 16일 ── { id: 'pinned-kr-isfahan-0316', timestamp: new Date('2026-03-16T10:00:00+09:00').getTime(), @@ -413,8 +598,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'military', language: 'ko', - lat: 32.65, - lng: 51.67, + lat: 32.65, lng: 51.67, }, { id: 'pinned-kr-ceasefire-0316', @@ -424,42 +608,18 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.voakorea.com', category: 'diplomacy', language: 'ko', - lat: 35.69, - lng: 51.39, + lat: 35.69, lng: 51.39, }, // ── 3월 15일 ── { - id: 'pinned-kr-hormuz-派兵-0315', + id: 'pinned-kr-hormuz-파병-0315', timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(), title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의', source: '뉴데일리', url: 'https://www.newdaily.co.kr', category: 'military', language: 'ko', - lat: 26.56, - lng: 56.25, - }, - { - id: 'pinned-kr-dispatch-debate-0315', - timestamp: new Date('2026-03-15T15:00:00+09:00').getTime(), - title: '[사설] 미국의 호르무즈 파병 요청, 이란전 참전 비칠 수 있어… 신중 대응 필요', - source: '경향신문', - url: 'https://www.khan.co.kr', - category: 'diplomacy', - language: 'ko', - lat: 37.57, - lng: 126.98, - }, - { - id: 'pinned-kr-turkey-nato-0315', - timestamp: new Date('2026-03-15T12:00:00+09:00').getTime(), - title: 'NATO 방공망, 튀르키예 상공서 이란 탄도미사일 3번째 요격… Article 5 논의 가속', - source: 'BBC Korea', - url: 'https://www.bbc.com/korean', - category: 'military', - language: 'ko', - lat: 37.00, - lng: 35.43, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-kospi-0315', @@ -469,8 +629,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://biz.newdaily.co.kr', category: 'oil', language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 37.57, lng: 126.98, }, // ── 3월 14일 ── { @@ -481,8 +640,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.mk.co.kr', category: 'oil', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-hormuz-shutdown-0314', @@ -492,8 +650,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.ifm.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-tanker-0314', @@ -503,8 +660,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.bloomberg.com', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, // ── 3월 13일 ── { @@ -515,8 +671,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-hormuz-0313b', @@ -526,8 +681,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.kbs.co.kr', category: 'military', language: 'ko', - lat: 26.30, - lng: 56.50, + lat: 26.30, lng: 56.50, }, { id: 'pinned-kr-ship-0312', @@ -537,14 +691,88 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.sbs.co.kr', category: 'shipping', language: 'ko', - lat: 26.20, - lng: 56.60, + lat: 26.20, lng: 56.60, }, ]; // ── Pinned OSINT articles (Korea maritime/security) ── const PINNED_KOREA: OsintItem[] = [ - // ── 3월 15일 최신 ── + // ── 3월 21일 최신 ── + { + id: 'pin-kr-cn-fishing-0321', + timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(), + title: '[속보] 중국어선 250척 이상 서해 EEZ 집단 침범… 해경 함정 12척 긴급 출동', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'fishing', + language: 'ko', + lat: 37.20, lng: 124.80, + }, + { + id: 'pin-kr-hormuz-talks-0321', + timestamp: new Date('2026-03-21T07:00:00+09:00').getTime(), + title: '정부 "이란 협상 타결 시 비축유 방출 중단"… 원유 수급 정상화 기대감', + source: '서울경제', + url: 'https://en.sedaily.com', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + // ── 3월 20일 ── + { + id: 'pin-kr-jmsdf-0320', + timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(), + title: '한미일 공동 해상 순찰 강화… F-35B 탑재 JMSDF 함정 동해 합류', + source: '국방일보', + url: 'https://www.kookbang.com', + category: 'military', + language: 'ko', + lat: 37.50, lng: 130.00, + }, + { + id: 'pin-kr-mof-38ships-0320', + timestamp: new Date('2026-03-20T11:00:00+09:00').getTime(), + title: '해양수산부, 호르무즈 인근 한국 선박 38척 안전 관리 중… 2척 귀항 성공', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 19일 ── + { + id: 'pin-kr-coast-guard-crackdown-0319', + timestamp: new Date('2026-03-19T10:00:00+09:00').getTime(), + title: '해경, 서해5도 꽃게 시즌 앞두고 중국 불법어선 특별단속… 18척 나포, 350척 검문', + source: '아시아경제', + url: 'https://www.asiae.co.kr', + category: 'fishing', + language: 'ko', + lat: 37.67, lng: 125.70, + }, + // ── 3월 18일 ── + { + id: 'pin-kr-nk-response-0318', + timestamp: new Date('2026-03-18T14:00:00+09:00').getTime(), + title: '북한, 이란 전황 관련 "반미 연대" 성명 발표… 군사정보 공유 가능성 주목', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'military', + language: 'ko', + lat: 39.00, lng: 125.75, + }, + // ── 3월 17일 ── + { + id: 'pin-kr-coast-guard-seizure-0317', + timestamp: new Date('2026-03-17T09:00:00+09:00').getTime(), + title: '[단독] 해경, 올해 최대 규모 중국어선 동시 나포… 부산 해경서 20척 압류·선원 47명 조사', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'fishing', + language: 'ko', + lat: 35.10, lng: 129.04, + }, + // ── 3월 15일 ── { id: 'pin-kr-nk-missile-0315', timestamp: new Date('2026-03-15T07:00:00+09:00').getTime(), @@ -553,8 +781,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'military', language: 'ko', - lat: 39.00, - lng: 127.00, + lat: 39.00, lng: 127.00, }, { id: 'pin-kr-nk-kimyojong-0315', @@ -564,8 +791,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://news.kbs.co.kr', category: 'military', language: 'ko', - lat: 39.00, - lng: 125.75, + lat: 39.00, lng: 125.75, }, { id: 'pin-kr-hormuz-deploy-0315', @@ -575,32 +801,9 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.newdaily.co.kr', category: 'military', language: 'ko', - lat: 26.56, - lng: 56.25, - }, - { - id: 'pin-kr-kctu-0315', - timestamp: new Date('2026-03-15T14:00:00+09:00').getTime(), - title: '민주노총 "호르무즈 파병은 침략전쟁 참전"… 파병 반대 성명', - source: '경향신문', - url: 'https://www.khan.co.kr', - category: 'diplomacy', - language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 26.56, lng: 56.25, }, // ── 3월 14일 ── - { - id: 'pin-kr-hormuz-zero-0314', - timestamp: new Date('2026-03-14T20:00:00+09:00').getTime(), - title: '[긴급] 호르무즈 해협 통항 제로… AIS 기준 양방향 선박 이동 완전 중단', - source: 'News1', - url: 'https://www.news1.kr', - category: 'shipping', - language: 'ko', - lat: 26.56, - lng: 56.25, - }, { id: 'pin-kr-freedom-shield-0314', timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(), @@ -609,8 +812,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://imnews.imbc.com', category: 'military', language: 'ko', - lat: 37.50, - lng: 127.00, + lat: 37.50, lng: 127.00, }, { id: 'pin-kr-hmm-0314', @@ -620,8 +822,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.haesanews.com', category: 'shipping', language: 'ko', - lat: 26.00, - lng: 56.00, + lat: 26.00, lng: 56.00, }, // ── 3월 13일 ── { @@ -632,8 +833,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://en.sedaily.com', category: 'oil', language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 37.57, lng: 126.98, }, { id: 'pin-kr-coast-guard-0313', @@ -643,8 +843,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.asiae.co.kr', category: 'maritime_traffic', language: 'ko', - lat: 37.67, - lng: 125.70, + lat: 37.67, lng: 125.70, }, { id: 'pin-kr-nk-destroyer-0312', @@ -654,8 +853,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.aei.org', category: 'military', language: 'ko', - lat: 39.80, - lng: 127.50, + lat: 39.80, lng: 127.50, }, { id: 'pin-kr-oil-reserve-0312', @@ -665,19 +863,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.hankyung.com', category: 'oil', language: 'ko', - lat: 36.97, - lng: 126.83, - }, - { - id: 'pin-kr-mof-emergency-0312', - timestamp: new Date('2026-03-12T10:00:00+09:00').getTime(), - title: '해양수산부 24시간 비상체제 가동… 호르무즈 인근 한국선박 40척 안전관리', - source: '해사신문', - url: 'https://www.haesanews.com', - category: 'shipping', - language: 'ko', - lat: 36.00, - lng: 127.00, + lat: 36.97, lng: 126.83, }, { id: 'pin-kr-chinese-fishing-0311', @@ -687,19 +873,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.asiaa.co.kr', category: 'fishing', language: 'ko', - lat: 37.67, - lng: 125.50, - }, - { - id: 'pin-kr-spring-safety-0311', - timestamp: new Date('2026-03-11T08:00:00+09:00').getTime(), - title: '해수부, 봄철 해양사고 예방대책 시행… 안개 충돌사고 대비 인천항 무인순찰로봇 도입', - source: 'iFM', - url: 'https://news.ifm.kr', - category: 'maritime_traffic', - language: 'ko', - lat: 37.45, - lng: 126.60, + lat: 37.67, lng: 125.50, }, { id: 'pin-kr-ships-hormuz-0311', @@ -709,8 +883,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.seoul.co.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a873fa9..f2b582e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -144,6 +144,16 @@ export interface LayerVisibility { oilFacilities: boolean; meFacilities: boolean; militaryOnly: boolean; + overseasUS: boolean; + overseasUK: boolean; + overseasIran: boolean; + overseasUAE: boolean; + overseasSaudi: boolean; + overseasOman: boolean; + overseasQatar: boolean; + overseasKuwait: boolean; + overseasIraq: boolean; + overseasBahrain: boolean; } export type AppMode = 'replay' | 'live'; diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 8a59536..550ba89 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -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() From e26a4db6e015e5a395d61224612de74a8fe25323 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:21:59 +0900 Subject: [PATCH 42/46] =?UTF-8?q?feat:=20=EC=8B=9C=EC=84=A4=20Popup=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=B5=ED=95=A9=20+=20LAYERS?= =?UTF-8?q?=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=ED=86=B5=EC=9D=BC=20+=20?= =?UTF-8?q?=ED=95=B4=EC=99=B8=EC=8B=9C=EC=84=A4=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 74 ++++--- frontend/src/components/common/LayerPanel.tsx | 192 +++++++++++++---- frontend/src/components/korea/KoreaMap.tsx | 200 ++++++++++++++++-- frontend/src/hooks/useStaticDeckLayers.ts | 10 +- 4 files changed, 385 insertions(+), 91 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 241a5a5..d94524a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,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 (이미 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() { @@ -630,48 +645,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 }, ], }, ]} diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index 1d1ecdc..b41844e 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -107,12 +107,27 @@ interface ExtraLayer { group?: string; } -const GROUP_META: Record = { - '항공망': { label: '항공망', color: '#22d3ee' }, - '국가기관망': { label: '국가기관망', color: '#f59e0b' }, - '해양안전': { label: '해양안전', color: '#3b82f6' }, +const GROUP_META: Record = { + '항공망': { label: '항공망', color: '#22d3ee' }, + '해양안전': { label: '해양안전', color: '#3b82f6' }, + '국가기관망': { label: '국가기관망', color: '#f59e0b' }, + '위험시설': { label: '위험시설', color: '#ef4444', superGroup: '위험/산업 인프라' }, + '에너지/발전시설': { label: '에너지/발전시설', color: '#a855f7', superGroup: '위험/산업 인프라' }, + '산업공정/제조시설': { label: '산업공정/제조시설', color: '#0ea5e9', superGroup: '위험/산업 인프라' }, }; +const SUPER_GROUP_META: Record = { + '위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' }, +}; + +interface OverseasItem { + key: string; + label: string; + color: string; + count?: number; + children?: OverseasItem[]; +} + interface LayerPanelProps { layers: Record; onToggle: (key: string) => void; @@ -122,6 +137,7 @@ interface LayerPanelProps { shipTotal: number; satelliteCount: number; extraLayers?: ExtraLayer[]; + overseasItems?: OverseasItem[]; hiddenAcCategories: Set; hiddenShipCategories: Set; onAcCategoryToggle: (cat: string) => void; @@ -143,6 +159,7 @@ export function LayerPanel({ shipTotal, satelliteCount, extraLayers, + overseasItems, hiddenAcCategories, hiddenShipCategories, onAcCategoryToggle, @@ -174,9 +191,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); + void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용 return (
@@ -186,7 +204,8 @@ export function LayerPanel({ {/* Ships tree */} a + b, 0)})`} + label="국적 분류" + count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)} color="#8b5cf6" active expandable @@ -342,7 +362,8 @@ export function LayerPanel({ {/* Aircraft tree */} onToggle('satellites')} @@ -421,47 +443,92 @@ export function LayerPanel({ ungrouped.push(el); } } + + // 수퍼그룹 별로 그룹 분류 + const superGrouped: Record = {}; // superGroup → groupNames[] + const noSuperGroup: string[] = []; + for (const groupName of Object.keys(grouped)) { + const sg = GROUP_META[groupName]?.superGroup; + if (sg) { + if (!superGrouped[sg]) superGrouped[sg] = []; + superGrouped[sg].push(groupName); + } else { + noSuperGroup.push(groupName); + } + } + + const renderGroup = (groupName: string, indent = false) => { + const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; + const isGroupExpanded = expanded.has(`group-${groupName}`); + const items = grouped[groupName] || []; + return ( +
+ toggleExpand(`group-${groupName}`)} + onExpand={() => toggleExpand(`group-${groupName}`)} + /> + {isGroupExpanded && ( +
+ {items.map(el => ( + onToggle(el.key)} + /> + ))} +
+ )} +
+ ); + }; + return ( <> - {/* Grouped layers */} - {Object.entries(grouped).map(([groupName, items]) => { - const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; - const isGroupExpanded = expanded.has(`group-${groupName}`); + {/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */} + {noSuperGroup.map(g => renderGroup(g))} + + {/* 수퍼그룹으로 묶인 그룹들 */} + {Object.entries(superGrouped).map(([sgName, groupNames]) => { + const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' }; + const isSgExpanded = expanded.has(`supergroup-${sgName}`); return ( -
+
toggleExpand(`group-${groupName}`)} - onExpand={() => toggleExpand(`group-${groupName}`)} + isExpanded={isSgExpanded} + onToggle={() => toggleExpand(`supergroup-${sgName}`)} + onExpand={() => toggleExpand(`supergroup-${sgName}`)} /> - {isGroupExpanded && ( + {isSgExpanded && (
- {items.map(el => ( - onToggle(el.key)} - /> - ))} + {groupNames.map(g => renderGroup(g, true))}
)}
); })} - {/* Ungrouped layers */} + + {/* 그룹 없는 개별 레이어 */} {ungrouped.map(el => ( onToggle(el.key)} @@ -473,14 +540,54 @@ export function LayerPanel({
- {/* Military only filter */} + {/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */} { + 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} - onToggle={() => onToggle('militaryOnly')} + active={expanded.has('overseas-section')} + expandable + isExpanded={expanded.has('overseas-section')} + onToggle={() => toggleExpand('overseas-section')} + onExpand={() => toggleExpand('overseas-section')} /> + {expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && ( +
+ {overseasItems.map(item => ( +
+ onToggle(item.key)} + onExpand={() => toggleExpand(`overseas-${item.key}`)} + /> + {item.children?.length && expanded.has(`overseas-${item.key}`) && ( +
+ {item.children.map(child => ( + onToggle(child.key)} + /> + ))} +
+ )} +
+ ))} +
+ )}
); @@ -495,6 +602,7 @@ function LayerTreeItem({ active, expandable, isExpanded, + count, onToggle, onExpand, }: { @@ -504,6 +612,7 @@ function LayerTreeItem({ active: boolean; expandable?: boolean; isExpanded?: boolean; + count?: number; onToggle: () => void; onExpand?: () => void; }) { @@ -523,13 +632,16 @@ function LayerTreeItem({ type="button" className={`layer-toggle ${active ? 'active' : ''}`} onClick={onToggle} - style={{ padding: 0, gap: '6px' }} + style={{ padding: 0, gap: '6px', flex: 1, width: '100%' }} > - {label} + {label} + {count != null && ( + {count} + )}
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 583d736..1a3bcac 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -632,34 +632,200 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ...selectedFleetLayers, ...analysisDeckLayers, ].filter(Boolean)} /> - {/* 정적 마커 클릭 Popup */} + {/* 정적 마커 클릭 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> = { + 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 = { + 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 = { 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 ( setStaticPickInfo(null)} - closeOnClick={false} - style={{ maxWidth: 280 }} + onClose={() => setStaticPickInfo(null)} closeOnClick={false} + maxWidth="280px" className="gl-popup" > -
-
- {obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind} +
+ {/* 컬러 헤더 */} +
+ {meta.icon} {title}
- {obj.description &&
{obj.description}
} - {obj.date &&
날짜: {obj.date} {obj.time || ''}
} - {obj.missileType &&
미사일: {obj.missileType}
} - {obj.range &&
사거리: {obj.range}
} - {obj.operator &&
운영: {obj.operator}
} - {obj.capacity &&
용량: {obj.capacity}
} - {staticPickInfo.kind === 'hazard' && obj.address && ( -
📍 {obj.address}
+ {/* 배지 행 */} +
+ + {meta.label} + + {flag && ( + + {flag} {countryName} + + )} + {kind === 'hazard' && ( + ⚠️ 위험시설 + )} + {kind === 'port' && ( + + {obj.type === 'major' ? '주요항' : '중소항'} + + )} + {kind === 'airport' && obj.intl && ( + 국제선 + )} +
+ {/* 설명 */} + {obj.description && ( +
{obj.description}
)} - {(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && ( -
유형: {obj.subType}
+ {obj.detail && ( +
{obj.detail}
)} + {obj.note && ( +
{obj.note}
+ )} + {/* 필드 그리드 */} +
+ {obj.operator &&
운영: {obj.operator}
} + {obj.capacity &&
규모: {obj.capacity}
} + {obj.output &&
출력: {obj.output}
} + {obj.source &&
연료: {obj.source}
} + {obj.capacityMW &&
용량: {obj.capacityMW}MW
} + {obj.turbines &&
터빈: {obj.turbines}기
} + {obj.status &&
상태: {obj.status}
} + {obj.year &&
연도: {obj.year}년
} + {obj.region &&
지역: {obj.region}
} + {obj.org &&
기관: {obj.org}
} + {obj.area &&
해역: {obj.area}
} + {obj.altitude &&
고도: {obj.altitude}
} + {obj.address &&
주소: {obj.address}
} + {obj.recentUse &&
최근 사용: {obj.recentUse}
} + {obj.recentIncidents != null &&
최근 1년: {obj.recentIncidents}건
} + {obj.icao &&
ICAO: {obj.icao}
} + {kind === 'nkMissile' && ( + <> + {obj.typeKo &&
미사일: {obj.typeKo}
} + {obj.date &&
발사일: {obj.date} {obj.time}
} + {obj.distanceKm &&
사거리: {obj.distanceKm}km
} + {obj.altitudeKm &&
최고고도: {obj.altitudeKm}km
} + {obj.flightMin &&
비행시간: {obj.flightMin}분
} + {obj.launchNameKo &&
발사지: {obj.launchNameKo}
} + + )} + {obj.name && obj.nameKo && obj.name !== obj.nameKo && ( +
영문: {obj.name}
+ )} +
+ {lat.toFixed(4)}°N, {lng.toFixed(4)}°E +
+
); diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 7a9a6fe..0d4c796 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -926,7 +926,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: hazardData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -963,7 +963,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => CN_META[d.subType]?.icon ?? '📍', - getSize: 16 * ss, + getSize: 16 * sc, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -982,7 +982,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -1018,7 +1018,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => JP_META[d.subType]?.icon ?? '📍', - getSize: 16 * ss, + getSize: 16 * sc, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -1037,7 +1037,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', From 5bf3ef8f7996b613807a3ab792a09f4bf0392ef6 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:22:26 +0900 Subject: [PATCH 43/46] =?UTF-8?q?fix:=20UX=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=E2=80=94=20=EC=A4=8C=20=EC=8A=A4=EC=BC=80=EC=9D=BC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20+=20=ED=98=B8=EB=B2=84=20=EC=BB=A4=EC=84=9C=20(#149?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/korea/KoreaMap.tsx | 18 +++++++++++++----- .../src/components/layers/DeckGLOverlay.tsx | 5 ++++- frontend/src/components/layers/ShipLayer.tsx | 12 ++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 1a3bcac..2c40b73 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -175,12 +175,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ); }, []); - // 줌 레벨별 아이콘/심볼 스케일 배율 + // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향 + // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향 const zoomScale = useMemo(() => { - if (zoomLevel <= 6) return 0.6; - if (zoomLevel <= 9) return 1.0; - if (zoomLevel <= 12) return 1.4; - return 1.8; + 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 diff --git a/frontend/src/components/layers/DeckGLOverlay.tsx b/frontend/src/components/layers/DeckGLOverlay.tsx index 154d59c..9e787db 100644 --- a/frontend/src/components/layers/DeckGLOverlay.tsx +++ b/frontend/src/components/layers/DeckGLOverlay.tsx @@ -12,7 +12,10 @@ interface Props { */ export function DeckGLOverlay({ layers }: Props) { const overlay = useControl( - () => new MapboxOverlay({ interleaved: true }), + () => new MapboxOverlay({ + interleaved: true, + getCursor: ({ isHovering }) => isHovering ? 'pointer' : '', + }), ); overlay.setProps({ layers }); return null; diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 664ee33..dd40192 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -559,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, @@ -577,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, From 852817d7ff9a0ab07bf408893d700c009f78b2f9 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:24:51 +0900 Subject: [PATCH 44/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-23)=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3848b4e..43f9459 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,7 +4,26 @@ ## [Unreleased] -## [2026-03-20.3] +## [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) @@ -12,22 +31,14 @@ ### 추가 - NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조 - -### 수정 -- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 - -## [2026-03-20.2] - -### 추가 -- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 +- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고 - AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 - 불법어선/다크베셀/중국어선감시 Python 분석 연동 - Backend vessel-analysis REST API + DB 테이블 복원 +- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 -## [2026-03-20] - -### 추가 -- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon) +### 수정 +- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 ## [2026-03-19] From cdc4cb57b1b96a048fabf55ca7a8808090241bca Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:31:38 +0900 Subject: [PATCH 45/46] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EC=84=A0=EA=B0=90=EC=8B=9C=20=ED=83=AD=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?+=20localStorage=20=EC=83=81=ED=83=9C=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=ED=99=94=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/VesselAnalysisService.java | 2 +- frontend/src/App.tsx | 30 +-- frontend/src/components/common/LayerPanel.tsx | 5 +- .../components/korea/AnalysisStatsPanel.tsx | 22 +- .../components/korea/FleetClusterLayer.tsx | 198 ++++++++++++------ frontend/src/components/korea/KoreaMap.tsx | 13 +- frontend/src/hooks/useKoreaFilters.ts | 14 +- frontend/src/hooks/useLocalStorage.ts | 68 ++++++ 8 files changed, 260 insertions(+), 92 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorage.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 775065e..0dfb546 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -34,7 +34,7 @@ public class VesselAnalysisService { } } - Instant since = Instant.now().minus(1, ChronoUnit.HOURS); + Instant since = Instant.now().minus(2, ChronoUnit.HOURS); // mmsi별 최신 analyzed_at 1건만 유지 Map latest = new LinkedHashMap<>(); for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94524a..bdd8948 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -68,9 +69,9 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); - const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); - const [layers, setLayers] = useState({ + const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite'); + const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran'); + const [layers, setLayers] = useLocalStorage('iranLayers', { events: true, aircraft: true, satellites: true, @@ -94,7 +95,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }); // Korea tab layer visibility (lifted from KoreaMap) - const [koreaLayers, setKoreaLayers] = useState>({ + const [koreaLayers, setKoreaLayers] = useLocalStorage>('koreaLayers', { ships: true, aircraft: true, satellites: true, @@ -134,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>(new Set()); - const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); + const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set()); + const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set()); const toggleAcCategory = useCallback((cat: string) => { setHiddenAcCategories(prev => { @@ -146,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 => { @@ -154,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>(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>(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(null); @@ -238,11 +239,12 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { koreaData.visibleShips, currentTime, vesselAnalysis.analysisMap, + koreaLayers.cnFishing, ); 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) => { diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index b41844e..0f4c3bf 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -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 = { @@ -172,7 +173,7 @@ export function LayerPanel({ onFishingNatToggle, }: LayerPanelProps) { const { t } = useTranslation(['common', 'ships']); - const [expanded, setExpanded] = useState>(new Set(['ships'])); + const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships'])); const [legendOpen, setLegendOpen] = useState>(new Set()); const toggleExpand = useCallback((key: string) => { @@ -181,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 => { diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index dd8b27f..836d8bc 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo } from 'react'; +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 { @@ -12,6 +13,7 @@ interface Props { allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; + onExpandedChange?: (expanded: boolean) => void; } interface VesselListItem { @@ -71,8 +73,16 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { - const [expanded, setExpanded] = useState(true); +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(null); const [selectedMmsi, setSelectedMmsi] = useState(null); const [showLegend, setShowLegend] = useState(false); @@ -124,8 +134,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, const panelStyle: React.CSSProperties = { position: 'absolute', - top: 60, - right: 10, + top: 10, + right: 50, zIndex: 10, minWidth: 200, maxWidth: 280, @@ -231,7 +241,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, -
- - {expanded && ( -
- {fleetList.length === 0 ? ( -
- 선단 데이터 없음 -
- ) : ( - fleetList.map(({ id, mmsiList }) => { +
+ {/* ── 선단 현황 섹션 ── */} +
toggleSection('fleet')}> + + 선단 현황 ({fleetList.length}개) + + +
+ {sectionExpanded.fleet && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList }) => { const company = companies.get(id); const companyName = company?.nameCn ?? `선단 #${id}`; const color = clusterColor(id); @@ -838,26 +863,76 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, }) )} - {/* 비허가 어구 그룹 섹션 */} - {gearGroupList.length > 0 && ( - <> -
-
- 비허가 어구 그룹 ({gearGroupList.length}개) +
+ )} + + {/* ── 조업구역내 어구 그룹 섹션 ── */} + {inZoneGearGroups.length > 0 && ( + <> +
toggleSection('inZone')}> + + 조업구역내 어구 ({inZoneGearGroups.length}개) + + +
+ {sectionExpanded.inZone && ( +
+ {inZoneGearGroups.map(({ name, parent, gears, zone }) => { + const isOpen = expandedGearGroup === name; + const accentColor = '#dc2626'; + return ( +
+
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} + > + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} + + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name} + {zone} + ({gears.length}) + +
+ {isOpen && ( +
+ {parent &&
모선: {parent.name || parent.mmsi}
} +
어구 목록:
+ {gears.map(g => ( +
+ {g.name || g.mmsi} + +
+ ))} +
+ )} +
+ ); + })}
- {gearGroupList.map(({ name, parent, gears }) => { + )} + + )} + + {/* ── 비허가 어구 그룹 섹션 ── */} + {outZoneGearGroups.length > 0 && ( + <> +
toggleSection('outZone')}> + + 비허가 어구 ({outZoneGearGroups.length}개) + + +
+ {sectionExpanded.outZone && ( +
+ {outZoneGearGroups.map(({ name, parent, gears }) => { const isOpen = expandedGearGroup === name; return ( -
+
); })} - - )} -
- )} +
+ )} + + )} +
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2c40b73..e608001 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -34,6 +34,7 @@ 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 { @@ -142,6 +143,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [selectedFleetData, setSelectedFleetData] = useState(null); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const [staticPickInfo, setStaticPickInfo] = useState(null); + const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); @@ -607,14 +609,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.cables && } {layers.cctv && } {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} - {koreaFilters.illegalFishing && } + {(koreaFilters.illegalFishing || layers.cnFishing) && } {layers.cnFishing && } {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} - {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( + {layers.cnFishing && ( {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} {staticPickInfo && (() => { @@ -928,6 +930,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF allShips={allShips ?? ships} onShipSelect={handleAnalysisShipSelect} onTrackLoad={handleTrackLoad} + onExpandedChange={setAnalysisPanelOpen} /> )} diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index f1dae97..253599d 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -1,4 +1,5 @@ import { useState, useMemo, useRef } from 'react'; +import { useLocalStorage } from './useLocalStorage'; import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { classifyFishingZone } from '../utils/fishingAnalysis'; @@ -43,8 +44,9 @@ export function useKoreaFilters( visibleShips: Ship[], currentTime: number, analysisMap?: Map, + cnFishingOn = false, ): UseKoreaFiltersResult { - const [filters, setFilters] = useState({ + const [filters, setFilters] = useLocalStorage('koreaFilters', { illegalFishing: false, illegalTransship: false, darkVessel: false, @@ -69,7 +71,8 @@ export function useKoreaFilters( filters.darkVessel || filters.cableWatch || filters.dokdoWatch || - filters.ferryWatch; + filters.ferryWatch || + cnFishingOn; // 불법환적 의심 선박 탐지 const transshipSuspects = useMemo(() => { @@ -326,9 +329,14 @@ export function useKoreaFilters( 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, analysisMap]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]); return { filters, diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..a2bdbed --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.ts @@ -0,0 +1,68 @@ +import { useState, useCallback } from 'react'; + +const PREFIX = 'kcg.'; + +/** + * localStorage 연동 useState — JSON 직렬화/역직렬화 자동 처리. + * 새 키가 추가된 Record 타입은 defaults와 자동 머지. + */ +export function useLocalStorage(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] { + const storageKey = PREFIX + key; + + const [value, setValueRaw] = useState(() => { + 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용 localStorage 연동 — 내부적으로 Array로 직렬화. + */ +export function useLocalStorageSet(key: string, defaults: Set): [Set, (v: Set | ((prev: Set) => Set)) => void] { + const storageKey = PREFIX + key; + + const [value, setValueRaw] = useState>(() => { + 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 | ((prev: Set) => Set)) => { + 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]; +} From 8ca89487e9e9e7d2426e078c405976ad799c6746 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:32:14 +0900 Subject: [PATCH 46/46] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-23.2)=20(#15?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 43f9459..b98c071 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [2026-03-23.2] + +### 추가 +- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링 +- 중국어선감시 탭: 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시 +- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지) +- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구) +- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤 +- localStorage 기반 레이어/필터 상태 영속화 (13개 항목) +- AI 분석 닫힘 시 위험도 마커 off + +### 변경 +- AI 분석 패널 위치 조정 (줌 버튼 간격 확보) +- 백엔드 vessel-analysis 조회 윈도우 1h → 2h + +### 수정 +- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거) + ## [2026-03-23] ### 추가