Merge pull request 'release: 2026-03-20.3 (deck.gl 전면 전환)' (#144) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m56s

This commit is contained in:
htlee 2026-03-20 21:22:39 +09:00
커밋 9f0f60159f
23개의 변경된 파일2159개의 추가작업 그리고 1429개의 파일을 삭제

파일 보기

@ -4,6 +4,18 @@
## [Unreleased]
## [2026-03-20.3]
### 변경
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
- 정적 마커 11종 deck.gl 전환 + 줌 레벨별 스케일
### 추가
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
### 수정
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
## [2026-03-20.2]
### 추가

파일 보기

@ -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,106 +12,17 @@ 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>
);
interface Props {
selected: CoastGuardFacility | null;
onClose: () => void;
}
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>
);
}
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}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{
@ -149,7 +59,5 @@ export function CoastGuardLayer() {
</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,15 +35,7 @@ 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"
@ -74,20 +53,5 @@ export function FishingZoneLayer() {
}}
/>
</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>
))}
</>
);
}

파일 보기

@ -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>
)}
</>
);
})()}

파일 보기

@ -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,46 +16,18 @@ 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;
}
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 && (() => {
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 (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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' }}>
@ -90,7 +60,4 @@ export function GovBuildingLayer() {
</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,53 +10,28 @@ 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();
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 && (() => {
if (!selected) return null;
const color = getColor(selected);
const info = getCountryInfo(selected);
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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' }}>
@ -105,7 +78,4 @@ export function KoreaAirportLayer() {
</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,57 +16,18 @@ 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);
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 && (() => {
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 (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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' }}>
@ -102,7 +61,4 @@ export function MilitaryBaseLayer() {
</div>
</Popup>
);
})()}
</>
);
}

파일 보기

@ -1,51 +1,18 @@
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;
}
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 && (() => {
export function NKLaunchLayer({ selected, onClose }: Props) {
if (!selected) return null;
const meta = NK_LAUNCH_TYPE_META[selected.type];
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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' }}>
@ -83,7 +50,4 @@ export function NKLaunchLayer() {
</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,59 +18,17 @@ 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>
);
interface Props {
selected: NavWarning | null;
onClose: () => void;
}
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>
);
}
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}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
<div style={{
@ -124,7 +81,5 @@ export function NavWarningLayer() {
>{t('navWarning.khoaLink')}</a>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -1,55 +1,19 @@
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>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="min-w-[260px] font-mono text-xs">
<div style={{
@ -89,7 +53,5 @@ export function PiracyLayer() {
</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,50 +13,17 @@ 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);
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 && (() => {
export function PortLayer({ selected, onClose }: Props) {
if (!selected) return null;
const s = getStyle(selected);
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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' }}>
@ -95,7 +60,4 @@ export function PortLayer() {
</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,57 +1,24 @@
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}
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' }}>
@ -89,7 +56,5 @@ export function WindFarmLayer() {
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -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;
}

파일 보기

@ -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]);
}

파일 보기

@ -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 };

파일 보기

@ -0,0 +1,3 @@
export function svgToDataUri(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}