refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링
- 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) <noreply@anthropic.com>
This commit is contained in:
부모
8323a248a7
커밋
f0c991c9ec
278
frontend/package-lock.json
generated
278
frontend/package-lock.json
generated
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<string, string> = {
|
||||
CRITICAL: '#ef4444',
|
||||
HIGH: '#f97316',
|
||||
MEDIUM: '#eab308',
|
||||
LOW: '#22c55e',
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
LOW: '정상',
|
||||
};
|
||||
|
||||
const RISK_MARKER_SIZE: Record<string, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
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 (
|
||||
<Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 삼각형 아이콘 */}
|
||||
<div style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${halfBase}px solid transparent`,
|
||||
borderRight: `${halfBase}px solid transparent`,
|
||||
borderBottom: `${triHeight}px solid ${color}`,
|
||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||
}} />
|
||||
{/* 위험도 텍스트 (한글) */}
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{RISK_LABEL[level] ?? level}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* CRITICAL 펄스 오버레이 */}
|
||||
{riskMarkers
|
||||
.filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL')
|
||||
.map(({ ship }) => (
|
||||
<Marker key={`pulse-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={riskPulseStyle('CRITICAL')} />
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 다크베셀 마커 */}
|
||||
{darkVesselMarkers.map(({ ship, dto }) => {
|
||||
const gapMin = dto.algorithms.darkVessel.gapDurationMin;
|
||||
return (
|
||||
<Marker key={`dark-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 보라 점선 원 */}
|
||||
<div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
border: '2px dashed #a855f7',
|
||||
boxShadow: '0 0 4px #a855f788',
|
||||
}} />
|
||||
{/* gap 라벨: "AIS 소실 N분" */}
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#a855f7',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* GPS 스푸핑 배지 */}
|
||||
{spoofingMarkers.map(({ ship, dto }) => {
|
||||
const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100);
|
||||
return (
|
||||
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 스푸핑 배지 */}
|
||||
<div style={{
|
||||
marginBottom: 14,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
backgroundColor: '#ef4444',
|
||||
borderRadius: 2,
|
||||
padding: '0 3px',
|
||||
textShadow: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{`GPS ${pct}%`}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 선단 leader 별 아이콘 */}
|
||||
{leaderShips.map(({ ship }) => (
|
||||
<Marker key={`leader-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
|
||||
@ -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 <TrawlNetIcon color={color} size={size} />;
|
||||
case 'gillnet':
|
||||
return <GillnetIcon color={color} size={size} />;
|
||||
case 'stow_net':
|
||||
return <StowNetIcon color={color} size={size} />;
|
||||
case 'purse_seine':
|
||||
return <PurseSeineIcon color={color} size={size} />;
|
||||
default:
|
||||
return <FishingNetIcon color={color} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** 선박 역할 추정 — 속도/크기/카테고리 기반 */
|
||||
function estimateRole(ship: Ship): { role: string; roleKo: string; color: string } {
|
||||
const mtCat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const speed = ship.speed;
|
||||
const len = ship.length || 0;
|
||||
|
||||
// 운반선: 화물선/대형/미분류 + 저속
|
||||
if (mtCat === 'cargo' || (mtCat === 'unspecified' && len > 50)) {
|
||||
return { role: 'FC', roleKo: '운반', color: '#f97316' };
|
||||
}
|
||||
|
||||
// 어선 분류
|
||||
if (mtCat === 'fishing' || ship.category === 'fishing') {
|
||||
// 대형(>200톤급, 길이 40m+) → 본선
|
||||
if (len >= 40) {
|
||||
return { role: 'PT', roleKo: '본선', color: '#ef4444' };
|
||||
}
|
||||
// 소형(<30m) + 트롤 속도 → 부속선
|
||||
if (len > 0 && len < 30 && speed >= 2 && speed <= 5) {
|
||||
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
|
||||
}
|
||||
// 기본 어선
|
||||
return { role: 'FV', roleKo: '어선', color: '#22c55e' };
|
||||
}
|
||||
|
||||
return { role: '', roleKo: '', color: '#6b7280' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 어구/어망 이름에서 모선명 추출
|
||||
@ -82,54 +31,7 @@ interface Props {
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
}
|
||||
|
||||
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) {
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 어구/어망 위치 마커 (모선 연결된 것) */}
|
||||
{gearLinks.map(link => (
|
||||
<Marker key={`gearlink-${link.gear.mmsi}`} longitude={link.gear.lng} latitude={link.gear.lat} anchor="center">
|
||||
<div style={{ filter: 'drop-shadow(0 0 3px #f9731688)', pointerEvents: 'none' }}>
|
||||
<FishingNetIcon color="#f97316" size={10} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: '#f97316', textAlign: 'center',
|
||||
textShadow: '0 0 2px #000', fontWeight: 700, marginTop: -1,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none',
|
||||
}}>
|
||||
← {link.parentName}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 조업 중 어선 — 어구 아이콘 */}
|
||||
{operating.map(({ ship, analysis }) => {
|
||||
const meta = GEAR_LABELS[analysis.gearType];
|
||||
return (
|
||||
<Marker key={`gear-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
filter: `drop-shadow(0 0 3px ${meta?.color || '#f97316'}88)`,
|
||||
opacity: 0.85,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<GearIcon gear={analysis.gearType} size={12} />
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 본선/부속선/어선 역할 라벨 (본선/부속/운반만, 최대 100개) */}
|
||||
{analyzed.filter(a => a.role.role && a.role.role !== 'FV').slice(0, 100).map(({ ship, role }) => (
|
||||
<Marker key={`role-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="top">
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: role.color,
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{role.roleKo}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 운반선 라벨 */}
|
||||
{carriers.map(s => (
|
||||
<Marker key={`carrier-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="top">
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: '#f97316',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
운반
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||
import { CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||
import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard';
|
||||
|
||||
const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
@ -13,143 +12,52 @@ const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
navy: '#3b82f6',
|
||||
};
|
||||
|
||||
const TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
hq: 24,
|
||||
regional: 20,
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
navy: 18,
|
||||
};
|
||||
|
||||
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
|
||||
function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) {
|
||||
const color = TYPE_COLOR[type];
|
||||
const isVts = type === 'vts';
|
||||
|
||||
if (type === 'navy') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="4" x2="12" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="4" r="2" fill={color} />
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke={color} strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVts) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
|
||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
|
||||
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
|
||||
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
|
||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
||||
{(type === 'hq' || type === 'regional') && (
|
||||
<circle cx="12" cy="9" r="1" fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: CoastGuardFacility | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CoastGuardLayer() {
|
||||
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
||||
export function CoastGuardLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{COAST_GUARD_FACILITIES.map(f => {
|
||||
const size = TYPE_SIZE[f.type];
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<CoastGuardIcon type={f.type} size={size} />
|
||||
{(f.type === 'hq' || f.type === 'regional') && (
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold text-white">
|
||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'navy' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #3b82f6, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#3b82f6]">
|
||||
{f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'vts' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
|
||||
VTS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
gap: 6, padding: '6px 10px',
|
||||
}}>
|
||||
{selected.type === 'navy' ? (
|
||||
<span style={{ fontSize: 16 }}>⚓</span>
|
||||
) : selected.type === 'vts' ? (
|
||||
<span style={{ fontSize: 16 }}>📡</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 16 }}>🚔</span>
|
||||
)}
|
||||
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#4dabf7',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
gap: 6, padding: '6px 10px',
|
||||
}}>
|
||||
{selected.type === 'navy' ? (
|
||||
<span style={{ fontSize: 16 }}>⚓</span>
|
||||
) : selected.type === 'vts' ? (
|
||||
<span style={{ fontSize: 16 }}>📡</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 16 }}>🚔</span>
|
||||
)}
|
||||
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#4dabf7',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,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<string, string> = {
|
||||
@ -16,18 +15,6 @@ const ZONE_LINE: Record<string, string> = {
|
||||
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 (
|
||||
<>
|
||||
<Source id="fishing-zones" type="geojson" data={fishingZonesData as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
id="fishing-zone-fill"
|
||||
type="fill"
|
||||
paint={{ 'fill-color': fillColor, 'fill-opacity': 1 }}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-zone-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': lineColor,
|
||||
'line-opacity': 1,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{labels.map(({ id, name, lng, lat }) => (
|
||||
<Marker key={`zone-${id}`} longitude={lng} latitude={lat} anchor="center">
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#fff',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
borderRadius: 3, padding: '1px 5px',
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
<Source id="fishing-zones" type="geojson" data={fishingZonesData as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
id="fishing-zone-fill"
|
||||
type="fill"
|
||||
paint={{ 'fill-color': fillColor, 'fill-opacity': 1 }}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-zone-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': lineColor,
|
||||
'line-opacity': 1,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, VesselAnalysisDto>;
|
||||
clusters: Map<number, string[]>;
|
||||
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<Map<number, FleetCompany>>(new Map());
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expandedFleet, setExpandedFleet] = useState<number | null>(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,
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 선택된 어구 그룹 하이라이트 + 모선 마커 */}
|
||||
{/* 선택된 어구 그룹 하이라이트 폴리곤 (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 && (
|
||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
||||
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
|
||||
</Source>
|
||||
)}
|
||||
{entry.parent && (
|
||||
<Marker longitude={entry.parent.lng} latitude={entry.parent.lat} anchor="center">
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
border: '3px solid #f97316',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.3)',
|
||||
boxShadow: '0 0 12px rgba(249, 115, 22, 0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<span style={{ fontSize: 9, fontWeight: 900, color: '#fff' }}>M</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8, fontWeight: 700, color: '#f97316',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
textAlign: 'center', marginTop: 2, whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{entry.parent.name || entry.parent.mmsi}
|
||||
</div>
|
||||
</Marker>
|
||||
)}
|
||||
</>
|
||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
||||
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
|
||||
</Source>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { GOV_BUILDINGS } from '../../data/govBuildings';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { GovBuilding } from '../../data/govBuildings';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -18,79 +16,48 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
|
||||
defense: { icon: '🛡️', label: '국방부', color: '#dc2626' },
|
||||
};
|
||||
|
||||
export function GovBuildingLayer() {
|
||||
const [selected, setSelected] = useState<GovBuilding | null>(null);
|
||||
interface Props {
|
||||
selected: GovBuilding | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GovBuildingLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<>
|
||||
{GOV_BUILDINGS.map(g => {
|
||||
const ts = TYPE_STYLE[g.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Marker key={g.id} longitude={g.lng} latitude={g.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(g); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { KOREAN_AIRPORTS } from '../../services/airports';
|
||||
import type { KoreanAirport } from '../../services/airports';
|
||||
|
||||
const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: string; label: string }> = {
|
||||
@ -12,100 +10,72 @@ const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: stri
|
||||
TW: { intl: '#10b981', domestic: '#059669', flag: '🇹🇼', label: '대만' },
|
||||
};
|
||||
|
||||
function getColor(ap: KoreanAirport) {
|
||||
const cc = COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
|
||||
function getColor(ap: KoreanAirport): string {
|
||||
const cc = COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
|
||||
return ap.intl ? cc.intl : cc.domestic;
|
||||
}
|
||||
|
||||
function getCountryInfo(ap: KoreanAirport) {
|
||||
return COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
|
||||
return COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
|
||||
}
|
||||
|
||||
export function KoreaAirportLayer() {
|
||||
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
||||
interface Props {
|
||||
selected: KoreanAirport | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KoreaAirportLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
const color = getColor(selected);
|
||||
const info = getCountryInfo(selected);
|
||||
return (
|
||||
<>
|
||||
{KOREAN_AIRPORTS.map(ap => {
|
||||
const color = getColor(ap);
|
||||
const size = ap.intl ? 20 : 16;
|
||||
return (
|
||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill={color} stroke="#fff" strokeWidth="0.3" />
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
|
||||
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const color = getColor(selected);
|
||||
const info = getCountryInfo(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{info.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
{selected.intl && (
|
||||
<span style={{
|
||||
background: color, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span style={{
|
||||
background: '#555', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
|
||||
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{info.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
{selected.intl && (
|
||||
<span style={{
|
||||
background: color, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span style={{
|
||||
background: '#555', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
|
||||
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,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<string | null>(null);
|
||||
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(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 (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -241,34 +438,6 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{/* 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 => (
|
||||
<Marker key={`illegal-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="center">
|
||||
<div style={{ position: 'relative', pointerEvents: 'none' }}>
|
||||
{/* 강조 펄스 링 — 선박 아이콘 중앙에 오버레이 */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
border: '2px solid #ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||
animation: 'pulse 2s infinite',
|
||||
boxShadow: '0 0 8px rgba(239, 68, 68, 0.4)',
|
||||
}} />
|
||||
{/* 선박명 — 아이콘 아래 */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 26, left: '50%', transform: 'translateX(-50%)',
|
||||
fontSize: 8, fontWeight: 700, color: '#ef4444',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{s.name || s.mmsi}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
@ -330,12 +499,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.cables && <SubmarineCableLayer />}
|
||||
{layers.cctv && <CctvLayer />}
|
||||
{layers.windFarm && <WindFarmLayer />}
|
||||
{layers.ports && <PortLayer />}
|
||||
{layers.militaryBases && <MilitaryBaseLayer />}
|
||||
{layers.govBuildings && <GovBuildingLayer />}
|
||||
{layers.nkLaunch && <NKLaunchLayer />}
|
||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
||||
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
||||
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{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 && <KoreaAirportLayer />}
|
||||
{layers.coastGuard && <CoastGuardLayer />}
|
||||
{layers.navWarning && <NavWarningLayer />}
|
||||
|
||||
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
|
||||
<DeckGLOverlay layers={[
|
||||
...staticDeckLayers,
|
||||
illegalFishingLayer,
|
||||
illegalFishingLabelLayer,
|
||||
zoneLabelsLayer,
|
||||
...selectedGearLayers,
|
||||
...analysisDeckLayers,
|
||||
].filter(Boolean)} />
|
||||
{/* 정적 마커 클릭 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 (
|
||||
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
||||
onClose={() => setStaticPickInfo(null)}
|
||||
closeOnClick={false}
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#333', padding: 4 }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
||||
{obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind}
|
||||
</div>
|
||||
{obj.description && <div style={{ fontSize: 10, color: '#666' }}>{obj.description}</div>}
|
||||
{obj.date && <div style={{ fontSize: 10 }}>날짜: {obj.date} {obj.time || ''}</div>}
|
||||
{obj.missileType && <div style={{ fontSize: 10 }}>미사일: {obj.missileType}</div>}
|
||||
{obj.range && <div style={{ fontSize: 10 }}>사거리: {obj.range}</div>}
|
||||
{obj.operator && <div style={{ fontSize: 10 }}>운영: {obj.operator}</div>}
|
||||
{obj.capacity && <div style={{ fontSize: 10 }}>용량: {obj.capacity}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||
{layers.eez && <EezLayer />}
|
||||
{layers.piracy && <PiracyLayer />}
|
||||
|
||||
{/* Filter Status Banner */}
|
||||
{(() => {
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { MILITARY_BASES } from '../../data/militaryBases';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { MilitaryBase } from '../../data/militaryBases';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -18,91 +16,49 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
|
||||
joint: { icon: '⭐', label: '합동기지', color: '#a78bfa' },
|
||||
};
|
||||
|
||||
function _MilIcon({ type, size = 16 }: { type: string; size?: number }) {
|
||||
const ts = TYPE_STYLE[type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,8 22,16 12,22 2,16 2,8" fill="rgba(0,0,0,0.6)" stroke={ts.color} strokeWidth="1.5" />
|
||||
<text x="12" y="14" textAnchor="middle" fontSize="9" fill={ts.color}>{ts.icon}</text>
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: MilitaryBase | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MilitaryBaseLayer() {
|
||||
const [selected, setSelected] = useState<MilitaryBase | null>(null);
|
||||
|
||||
export function MilitaryBaseLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<>
|
||||
{MILITARY_BASES.map(base => {
|
||||
const _cs = COUNTRY_STYLE[base.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[base.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<Marker key={base.id} longitude={base.lng} latitude={base.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(base); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">시설명 : </span><strong>{selected.name}</strong></div>
|
||||
<div><span className="popup-label">유형 : </span>{ts.label}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">시설명 : </span><strong>{selected.name}</strong></div>
|
||||
<div><span className="popup-label">유형 : </span>{ts.label}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,89 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||
|
||||
export function NKLaunchLayer() {
|
||||
const [selected, setSelected] = useState<NKLaunchSite | null>(null);
|
||||
interface Props {
|
||||
selected: NKLaunchSite | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NKLaunchLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const meta = NK_LAUNCH_TYPE_META[selected.type];
|
||||
return (
|
||||
<>
|
||||
{NK_LAUNCH_SITES.map(site => {
|
||||
const meta = NK_LAUNCH_TYPE_META[site.type];
|
||||
const isArtillery = site.type === 'artillery' || site.type === 'mlrs';
|
||||
const size = isArtillery ? 14 : 18;
|
||||
return (
|
||||
<Marker key={site.id} longitude={site.lng} latitude={site.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(site); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${meta.color}aa)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: isArtillery ? '50%' : 4,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: `2px solid ${meta.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: isArtillery ? 8 : 10,
|
||||
}}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: meta.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const meta = NK_LAUNCH_TYPE_META[selected.type];
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#f97316', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
북한
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
{selected.recentUse && (
|
||||
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
|
||||
최근: {selected.recentUse}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#f97316', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
북한
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
{selected.recentUse && (
|
||||
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
|
||||
최근: {selected.recentUse}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { useMemo } from 'react';
|
||||
import { Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
||||
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||
import type { Ship } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
|
||||
function isToday(dateStr: string): boolean {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return dateStr === today;
|
||||
}
|
||||
|
||||
function getMissileColor(type: string): string {
|
||||
if (type.includes('ICBM')) return '#dc2626';
|
||||
if (type.includes('IRBM')) return '#ef4444';
|
||||
@ -27,11 +22,11 @@ function distKm(lat1: number, lng1: number, lat2: number, lng2: number): number
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
selected: NKMissileEvent | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NKMissileEventLayer({ ships }: Props) {
|
||||
const [selected, setSelected] = useState<NKMissileEvent | null>(null);
|
||||
|
||||
export function NKMissileEventLayer({ ships, selected, onClose }: Props) {
|
||||
const lineGeoJSON = useMemo(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: NK_MISSILE_EVENTS.map(ev => ({
|
||||
@ -51,7 +46,7 @@ export function NKMissileEventLayer({ ships }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 궤적 라인 */}
|
||||
{/* 궤적 라인 — MapLibre Source/Layer 유지 */}
|
||||
<Source id="nk-missile-lines" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="nk-missile-line-layer"
|
||||
@ -65,62 +60,12 @@ export function NKMissileEventLayer({ ships }: Props) {
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 발사 지점 (▲) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`launch-${ev.id}`} longitude={ev.launchLng} latitude={ev.launchLat} anchor="center">
|
||||
<div style={{ filter: `drop-shadow(0 0 4px ${color}aa)`, opacity: today ? 1 : 0.35 }}>
|
||||
<svg width={12} height={12} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,20 2,20" fill={color} stroke="#fff" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 (✕ + 정보 라벨) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`impact-${ev.id}`} longitude={ev.impactLng} latitude={ev.impactLat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ev); }}>
|
||||
<div className="cursor-pointer flex flex-col items-center" style={{
|
||||
filter: `drop-shadow(0 0 ${today ? '6px' : '3px'} ${color})`,
|
||||
opacity: today ? 1 : 0.4,
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
<svg width={16} height={16} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="7" y1="7" x2="17" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="17" y1="7" x2="7" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
{today && (
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke={color} strokeWidth="1" opacity="0.4">
|
||||
<animate attributeName="r" values="10;18;10" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;0;0.4" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 5, color, fontWeight: 700, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{ev.date.slice(5)} {ev.time} ← {ev.launchNameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 팝업 */}
|
||||
{selected && (() => {
|
||||
const color = getMissileColor(selected.type);
|
||||
return (
|
||||
<Popup longitude={selected.impactLng} latitude={selected.impactLat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 260 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
|
||||
import { NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
|
||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning';
|
||||
|
||||
const LEVEL_COLOR: Record<NavWarningLevel, string> = {
|
||||
@ -19,112 +18,68 @@ const ORG_COLOR: Record<TrainingOrg, string> = {
|
||||
'국과연': '#eab308',
|
||||
};
|
||||
|
||||
function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) {
|
||||
const color = ORG_COLOR[org];
|
||||
|
||||
if (level === 'danger') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="12" y1="9" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: NavWarning | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NavWarningLayer() {
|
||||
const [selected, setSelected] = useState<NavWarning | null>(null);
|
||||
export function NavWarningLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{NAV_WARNINGS.map(w => {
|
||||
const color = ORG_COLOR[w.org];
|
||||
const size = w.level === 'danger' ? 16 : 14;
|
||||
return (
|
||||
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 4px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<WarningIcon level={w.level} org={w.org} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="whitespace-nowrap font-bold tracking-wide">
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
color: '#fff',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
margin: '-10px -10px 0',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
}}>
|
||||
{selected.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
color: '#fff',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3,
|
||||
}}>
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
||||
>{t('navWarning.khoaLink')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
color: '#fff',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
margin: '-10px -10px 0',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
}}>
|
||||
{selected.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
color: '#fff',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3,
|
||||
}}>
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
||||
>{t('navWarning.khoaLink')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,95 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
|
||||
import { PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
|
||||
import type { PiracyZone } from '../../services/piracy';
|
||||
|
||||
function SkullIcon({ color, size }: { color: string; size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
||||
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
||||
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: PiracyZone | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PiracyLayer() {
|
||||
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
||||
export function PiracyLayer({ selected, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{PIRACY_ZONES.map(zone => {
|
||||
const color = PIRACY_LEVEL_COLOR[zone.level];
|
||||
const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20;
|
||||
return (
|
||||
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
||||
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
}} className="flex flex-col items-center">
|
||||
<SkullIcon color={color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 7, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
|
||||
{PIRACY_LEVEL_LABEL[zone.level]}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[260px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||
<span className="text-sm">☠️</span>
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[260px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||
<span className="text-sm">☠️</span>
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.name}
|
||||
</span>
|
||||
{selected.recentIncidents != null && (
|
||||
<span style={{
|
||||
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.name}
|
||||
</span>
|
||||
{selected.recentIncidents != null && (
|
||||
<span style={{
|
||||
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-[#999]">
|
||||
{selected.detail}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-[#999]">
|
||||
{selected.detail}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { Port } from '../../data/ports';
|
||||
|
||||
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
|
||||
@ -15,87 +13,51 @@ function getStyle(p: Port) {
|
||||
return COUNTRY_STYLE[p.country] || COUNTRY_STYLE.KR;
|
||||
}
|
||||
|
||||
function AnchorIcon({ color, size = 14 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="5" r="2.5" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
<line x1="12" y1="7.5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="7" y1="12" x2="17" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
interface Props {
|
||||
selected: Port | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PortLayer() {
|
||||
const [selected, setSelected] = useState<Port | null>(null);
|
||||
|
||||
export function PortLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
const s = getStyle(selected);
|
||||
return (
|
||||
<>
|
||||
{EAST_ASIA_PORTS.map(p => {
|
||||
const s = getStyle(p);
|
||||
const size = p.type === 'major' ? 16 : 12;
|
||||
return (
|
||||
<Marker key={p.id} longitude={p.lng} latitude={p.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(p); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 2px ${s.color}88)` }}
|
||||
>
|
||||
<AnchorIcon color={s.color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color: s.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{p.nameKo.replace('항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const s = getStyle(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{s.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>⚓ {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: s.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.type === 'major' ? '주요항만' : '항만'}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">항구 : </span><strong>{selected.nameKo}</strong></div>
|
||||
<div><span className="popup-label">영문 : </span>{selected.name}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{s.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>⚓ {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: s.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.type === 'major' ? '주요항만' : '항만'}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">항구 : </span><strong>{selected.nameKo}</strong></div>
|
||||
<div><span className="popup-label">영문 : </span>{selected.name}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,26 @@ export function SubmarineCableLayer() {
|
||||
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
|
||||
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
|
||||
|
||||
// 날짜변경선(180도) 보정: 연속 좌표가 180도를 넘으면 경도를 연속으로 만듦
|
||||
// 예: [170, lat] → [-170, lat] 를 [170, lat] → [190, lat] 로 변환
|
||||
function fixDateline(route: number[][]): number[][] {
|
||||
const fixed: number[][] = [];
|
||||
for (let i = 0; i < route.length; i++) {
|
||||
const [lng, lat] = route[i];
|
||||
if (i === 0) {
|
||||
fixed.push([lng, lat]);
|
||||
continue;
|
||||
}
|
||||
const prevLng = fixed[i - 1][0];
|
||||
let newLng = lng;
|
||||
// 이전 경도와 180도 이상 차이나면 보정
|
||||
while (newLng - prevLng > 180) newLng -= 360;
|
||||
while (prevLng - newLng > 180) newLng += 360;
|
||||
fixed.push([newLng, lat]);
|
||||
}
|
||||
return fixed;
|
||||
}
|
||||
|
||||
// Build GeoJSON for all cables
|
||||
const geojson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
@ -19,7 +39,7 @@ export function SubmarineCableLayer() {
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: cable.route,
|
||||
coordinates: fixDateline(cable.route),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
@ -1,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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="9" r="1.8" fill={COLOR} />
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={COLOR} opacity="0.9" />
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={COLOR} opacity="0.9" />
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={COLOR} opacity="0.9" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'운영중': '#22c55e',
|
||||
'건설중': '#eab308',
|
||||
'계획': '#64748b',
|
||||
};
|
||||
|
||||
export function WindFarmLayer() {
|
||||
const [selected, setSelected] = useState<WindFarm | null>(null);
|
||||
interface Props {
|
||||
selected: WindFarm | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WindFarmLayer({ selected, onClose }: Props) {
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<>
|
||||
{KOREA_WIND_FARMS.map(wf => (
|
||||
<Marker key={wf.id} longitude={wf.lng} latitude={wf.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(wf); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${COLOR}88)` }}
|
||||
>
|
||||
<WindTurbineIcon size={18} />
|
||||
<div style={{
|
||||
fontSize: 6, color: COLOR, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🌀</span>
|
||||
<strong>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: COLOR, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
해상풍력
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">용량 : </span><strong>{selected.capacityMW} MW</strong></div>
|
||||
<div><span className="popup-label">터빈 : </span><strong>{selected.turbines}기</strong></div>
|
||||
{selected.year && <div><span className="popup-label">준공 : </span><strong>{selected.year}년</strong></div>}
|
||||
<div><span className="popup-label">지역 : </span>{selected.region}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🌀</span>
|
||||
<strong>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: COLOR, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
해상풍력
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">용량 : </span><strong>{selected.capacityMW} MW</strong></div>
|
||||
<div><span className="popup-label">터빈 : </span><strong>{selected.turbines}기</strong></div>
|
||||
{selected.year && <div><span className="popup-label">준공 : </span><strong>{selected.year}년</strong></div>}
|
||||
<div><span className="popup-label">지역 : </span>{selected.region}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
19
frontend/src/components/layers/DeckGLOverlay.tsx
Normal file
19
frontend/src/components/layers/DeckGLOverlay.tsx
Normal file
@ -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<MapboxOverlay>(
|
||||
() => new MapboxOverlay({ interleaved: true }),
|
||||
);
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
187
frontend/src/hooks/useAnalysisDeckLayers.ts
Normal file
187
frontend/src/hooks/useAnalysisDeckLayers.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
dto: VesselAnalysisDto;
|
||||
}
|
||||
|
||||
// RISK_RGBA: [r, g, b, a] 충전색
|
||||
const RISK_RGBA: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 60],
|
||||
HIGH: [249, 115, 22, 50],
|
||||
MEDIUM: [234, 179, 8, 40],
|
||||
};
|
||||
|
||||
// 테두리색
|
||||
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 230],
|
||||
HIGH: [249, 115, 22, 210],
|
||||
MEDIUM: [234, 179, 8, 190],
|
||||
};
|
||||
|
||||
// 픽셀 반경
|
||||
const RISK_SIZE: Record<string, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
MEDIUM: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
|
||||
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
|
||||
*/
|
||||
export function useAnalysisDeckLayers(
|
||||
analysisMap: Map<string, VesselAnalysisDto>,
|
||||
ships: Ship[],
|
||||
activeFilter: string | null,
|
||||
sizeScale: number = 1.0,
|
||||
): Layer[] {
|
||||
return useMemo(() => {
|
||||
if (analysisMap.size === 0) return [];
|
||||
|
||||
const analyzedShips: AnalyzedShip[] = ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
|
||||
|
||||
const riskData = analyzedShips
|
||||
.filter(({ dto }) => {
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99;
|
||||
const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99;
|
||||
return pa - pb;
|
||||
})
|
||||
.slice(0, 100);
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// 위험도 원형 마커
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnalyzedShip>({
|
||||
id: 'risk-markers',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// 위험도 라벨 (선박명 + 위험도 등급)
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'risk-labels',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level;
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
|
||||
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
|
||||
if (activeFilter === 'darkVessel') {
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
|
||||
if (darkData.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnalyzedShip>({
|
||||
id: 'dark-vessel-markers',
|
||||
data: darkData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: 12 * sizeScale,
|
||||
getFillColor: [168, 85, 247, 40],
|
||||
getLineColor: [168, 85, 247, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// 다크베셀 gap 라벨
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'dark-vessel-labels',
|
||||
data: darkData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GPS 스푸핑 라벨
|
||||
const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
||||
if (spoofData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
id: 'spoof-labels',
|
||||
data: spoofData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'start',
|
||||
getPixelOffset: [12, -8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [analysisMap, ships, activeFilter, sizeScale]);
|
||||
}
|
||||
893
frontend/src/hooks/useStaticDeckLayers.ts
Normal file
893
frontend/src/hooks/useStaticDeckLayers.ts
Normal file
@ -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<string, string> = {
|
||||
KR: '#3b82f6',
|
||||
CN: '#ef4444',
|
||||
JP: '#f472b6',
|
||||
KP: '#f97316',
|
||||
TW: '#10b981',
|
||||
};
|
||||
|
||||
function portSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="5" r="2.5" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||
<line x1="12" y1="7.5" x2="12" y2="21" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="12" x2="17" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Wind Turbine SVG ─────────────────────────────────────────────────────────
|
||||
|
||||
const WIND_COLOR = '#00bcd4';
|
||||
|
||||
function windTurbineSvg(size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── CoastGuard SVG ───────────────────────────────────────────────────────────
|
||||
|
||||
const CG_TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
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 `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="4" x2="12" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="4" r="2" fill="${color}"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
if (type === 'vts') {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="18" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" stroke-width="1"/>
|
||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" stroke-width="1"/>
|
||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" stroke-width="1"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const CG_TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
hq: 24,
|
||||
regional: 20,
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
navy: 18,
|
||||
};
|
||||
|
||||
// ─── Airport SVG ─────────────────────────────────────────────────────────────
|
||||
|
||||
const AP_COUNTRY_COLOR: Record<string, { intl: string; domestic: string }> = {
|
||||
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 `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── NavWarning SVG ───────────────────────────────────────────────────────────
|
||||
|
||||
const NW_ORG_COLOR: Record<TrainingOrg, string> = {
|
||||
'해군': '#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 `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="9" x2="12" y2="14" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="17" r="1" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="16" r="1" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Piracy SVG ───────────────────────────────────────────────────────────────
|
||||
|
||||
function piracySvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.5"/>
|
||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||
<path d="M11 13 L12 14.5 L13 13" stroke="${color}" stroke-width="1" fill="none"/>
|
||||
<path d="M7 17 Q12 21 17 17" stroke="${color}" stroke-width="1.2" fill="none"/>
|
||||
<line x1="4" y1="20" x2="20" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="20" y1="20" x2="4" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── 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 `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 22,20 2,20" fill="${color}" stroke="#fff" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function missileImpactSvg(color: string): string {
|
||||
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="7" x2="17" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="17" y1="7" x2="7" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Infra SVG ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INFRA_SOURCE_COLOR: Record<string, string> = {
|
||||
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 `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="1" fill="#111" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── 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<string, string>();
|
||||
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<Port>({
|
||||
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<Port>) => {
|
||||
if (info.object) config.onPick({ kind: 'port', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<Port>({
|
||||
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<WindFarm>({
|
||||
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<WindFarm>) => {
|
||||
if (info.object) config.onPick({ kind: 'windFarm', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<WindFarm>({
|
||||
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<CoastGuardType, string>();
|
||||
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<CoastGuardFacility>({
|
||||
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<CoastGuardFacility>) => {
|
||||
if (info.object) config.onPick({ kind: 'coastGuard', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<CoastGuardFacility>({
|
||||
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<string, string>();
|
||||
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<KoreanAirport>({
|
||||
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<KoreanAirport>) => {
|
||||
if (info.object) config.onPick({ kind: 'airport', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<KoreanAirport>({
|
||||
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<string, string>();
|
||||
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<NavWarning>({
|
||||
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<NavWarning>) => {
|
||||
if (info.object) config.onPick({ kind: 'navWarning', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<NavWarning>({
|
||||
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<string, string>();
|
||||
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<PiracyZone>({
|
||||
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<PiracyZone>) => {
|
||||
if (info.object) config.onPick({ kind: 'piracy', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<PiracyZone>({
|
||||
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<string, string> = {
|
||||
naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e',
|
||||
missile: '#ef4444', joint: '#a78bfa',
|
||||
};
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<MilitaryBase>({
|
||||
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<MilitaryBase>) => {
|
||||
if (info.object) config.onPick({ kind: 'militaryBase', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<MilitaryBase>({
|
||||
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<string, string> = {
|
||||
executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444',
|
||||
intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626',
|
||||
};
|
||||
const GOV_TYPE_ICON: Record<string, string> = {
|
||||
executive: '🏛', legislature: '🏛', military_hq: '⭐',
|
||||
intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<GovBuilding>({
|
||||
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<GovBuilding>) => {
|
||||
if (info.object) config.onPick({ kind: 'govBuilding', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<GovBuilding>({
|
||||
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<NKLaunchSite>({
|
||||
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<NKLaunchSite>) => {
|
||||
if (info.object) config.onPick({ kind: 'nkLaunch', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<NKLaunchSite>({
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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<LaunchPoint>({
|
||||
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<ImpactPoint>({
|
||||
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<ImpactPoint>) => {
|
||||
if (info.object) config.onPick({ kind: 'nkMissile', object: info.object.ev });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<ImpactPoint>({
|
||||
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<string, string>();
|
||||
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<PowerFacility>({
|
||||
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<PowerFacility>) => {
|
||||
if (info.object) config.onPick({ kind: 'infra', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new IconLayer<PowerFacility>({
|
||||
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<PowerFacility>) => {
|
||||
if (info.object) config.onPick({ kind: 'infra', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<PowerFacility>({
|
||||
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 };
|
||||
3
frontend/src/utils/svgToDataUri.ts
Normal file
3
frontend/src/utils/svgToDataUri.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function svgToDataUri(svg: string): string {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user