From f0c991c9ecbbf7ad97fd0cd0656c91a4175189a1 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 21:11:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20deck.gl=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=E2=80=94=20DOM=20Marker=20=E2=86=92=20GPU?= =?UTF-8?q?=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 2/3] =?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 3/3] =?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]