From 498c198336d569ed0fc6672d9ad0732a8b70f2bf Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 14:51:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=9E=80=20=EC=8B=9C=EC=84=A4?= =?UTF-8?q?=20deck.gl=20SVG=20=EC=A0=84=ED=99=98=20+=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=ED=92=88=EC=A7=88=20=ED=86=B5=ED=95=A9=20+=20AI=20?= =?UTF-8?q?=EC=B1=97=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 19 + frontend/src/components/common/EventLog.tsx | 13 +- frontend/src/components/common/LayerPanel.tsx | 123 +++++-- frontend/src/components/iran/AirportLayer.ts | 151 ++++++++ frontend/src/components/iran/AirportLayer.tsx | 133 ------- .../src/components/iran/IranDashboard.tsx | 34 +- .../components/iran/MEEnergyHazardLayer.tsx | 219 ++++++++++++ .../src/components/iran/MEFacilityLayer.ts | 179 ++++++++++ .../src/components/iran/MEFacilityLayer.tsx | 80 ----- .../src/components/iran/OilFacilityLayer.ts | 252 +++++++++++++ .../src/components/iran/OilFacilityLayer.tsx | 320 ----------------- frontend/src/components/iran/ReplayMap.tsx | 211 ++++++++++- frontend/src/components/iran/SatelliteMap.tsx | 210 ++++++++++- .../iran/createIranAirportLayers.ts | 103 ++++++ .../components/iran/createIranOilLayers.ts | 153 ++++++++ .../components/iran/createMEFacilityLayers.ts | 148 ++++++++ frontend/src/components/korea/AiChatPanel.tsx | 265 ++++++++++++++ frontend/src/components/korea/KoreaMap.tsx | 29 +- frontend/src/data/meEnergyHazardFacilities.ts | 194 ++++++++++ frontend/src/data/sampleData.ts | 42 +++ .../src/data/zones/fishing-zones-wgs84.json | 38 +- .../src/hooks/layers/createFacilityLayers.ts | 28 +- .../src/hooks/layers/createMilitaryLayers.ts | 331 +++++++++++++++--- .../hooks/layers/createNavigationLayers.ts | 28 +- frontend/src/hooks/layers/createPortLayers.ts | 14 +- frontend/src/hooks/useAnalysisDeckLayers.ts | 21 +- frontend/src/types.ts | 2 +- frontend/vite.config.ts | 5 + 28 files changed, 2638 insertions(+), 707 deletions(-) create mode 100644 frontend/src/components/iran/AirportLayer.ts delete mode 100644 frontend/src/components/iran/AirportLayer.tsx create mode 100644 frontend/src/components/iran/MEEnergyHazardLayer.tsx create mode 100644 frontend/src/components/iran/MEFacilityLayer.ts delete mode 100644 frontend/src/components/iran/MEFacilityLayer.tsx create mode 100644 frontend/src/components/iran/OilFacilityLayer.ts delete mode 100644 frontend/src/components/iran/OilFacilityLayer.tsx create mode 100644 frontend/src/components/iran/createIranAirportLayers.ts create mode 100644 frontend/src/components/iran/createIranOilLayers.ts create mode 100644 frontend/src/components/iran/createMEFacilityLayers.ts create mode 100644 frontend/src/components/korea/AiChatPanel.tsx create mode 100644 frontend/src/data/meEnergyHazardFacilities.ts diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7dab166..6a92c46 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,25 @@ ## [Unreleased] +### 추가 +- 이란 시설 deck.gl SVG 전환: OilFacility/Airport/MEFacility/MEEnergyHazard → IconLayer(SVG) + TextLayer +- 26개 고유 SVG 아이콘 (배경 원형 + 색상 테두리 + 고유 실루엣) +- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities) +- 나탄즈-디모나 핵시설 교차공격 리플레이 이벤트 (D+20) +- AI 해양분석 챗 UI (AiChatPanel, API placeholder) +- LayerPanel 해외시설 3단계 트리 (국가→카테고리→하위시설) + +### 변경 +- 한국 군사/정부/NK 발사장 아이콘: emoji → SVG IconLayer 업그레이드 (19종) +- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth:8) — 사막/위성 배경 가독성 +- 라벨 폰트 크기 ~1.2배 상향 (이란/한국 공통) +- ReplayMap/SatelliteMap: DeckGLOverlay + 줌 스케일 연동 + +### 수정 +- IranDashboard LayerPanel 카운트 전수 보정 (하드코딩→실제 데이터 기반) +- fishing-zones GeoJSON 좌표 보정 +- overseas 국가 키: overseasUK → overseasIsrael + ## [2026-03-23.4] ### 추가 diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index 12bdb9b..b1ae5b2 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -6,6 +6,7 @@ import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../ import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis'; import type { FishingGearType } from '../../utils/fishingAnalysis'; +import { AiChatPanel } from '../korea/AiChatPanel'; type DashboardTab = 'iran' | 'korea'; @@ -349,7 +350,7 @@ function useTimeAgo() { export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) { const { t } = useTranslation(['common', 'events', 'ships']); const timeAgo = useTimeAgo(); - const [collapsed, setCollapsed] = useState>(new Set(['kr-ships', 'cn-ships'])); + const [collapsed, setCollapsed] = useState>(new Set(['kr-ships', 'cn-ships', 'cn-fishing'])); const toggleCollapse = useCallback((key: string) => { setCollapsed(prev => { const next = new Set(prev); @@ -879,6 +880,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, )} )} + + {/* AI 해양분석 챗 — 한국 탭 전용 */} + {dashboardTab === 'korea' && ( + + )} ); } diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index 0f4c3bf..e604861 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -129,6 +129,18 @@ interface OverseasItem { children?: OverseasItem[]; } +function countOverseasActiveLeaves(items: OverseasItem[], layers: Record): number { + let count = 0; + for (const item of items) { + if (item.children?.length) { + count += countOverseasActiveLeaves(item.children, layers); + } else if (layers[item.key]) { + count += (item.count ?? 1); + } + } + return count; +} + interface LayerPanelProps { layers: Record; onToggle: (key: string) => void; @@ -545,11 +557,7 @@ export function LayerPanel({ { - const parentOn = layers[item.key] ? 1 : 0; - const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0; - return sum + parentOn + childrenOn; - }, 0) ?? 0} + count={overseasItems ? countOverseasActiveLeaves(overseasItems, layers) : 0} color="#f97316" active={expanded.has('overseas-section')} expandable @@ -560,32 +568,16 @@ export function LayerPanel({ {expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
{overseasItems.map(item => ( -
- onToggle(item.key)} - onExpand={() => toggleExpand(`overseas-${item.key}`)} - /> - {item.children?.length && expanded.has(`overseas-${item.key}`) && ( -
- {item.children.map(child => ( - onToggle(child.key)} - /> - ))} -
- )} -
+ ))}
)} @@ -594,6 +586,75 @@ export function LayerPanel({ ); } +/* ── Overseas 3-level tree node ────────────────────── */ + +function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll, toggleExpand }: { + item: OverseasItem; + depth: number; + layers: Record; + expanded: Set; + onToggle: (key: string) => void; + onToggleAll: (key: string) => void; + toggleExpand: (key: string) => void; +}) { + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expanded.has(item.key); + const isActive = hasChildren + ? item.children!.some(c => c.children?.length ? c.children.some(gc => layers[gc.key]) : layers[c.key]) + : layers[item.key]; + const leafCount = hasChildren ? countOverseasActiveLeaves([item], layers) : (item.count ?? 0); + + const handleToggle = () => { + if (hasChildren) { + // 부모 토글 → 모든 하위 리프 on/off + const allLeaves: string[] = []; + const collectLeaves = (node: OverseasItem) => { + if (node.children?.length) node.children.forEach(collectLeaves); + else allLeaves.push(node.key); + }; + collectLeaves(item); + const allOn = allLeaves.every(k => layers[k]); + for (const k of allLeaves) { + if (allOn || !layers[k]) onToggleAll(k); + } + } else { + onToggle(item.key); + } + }; + + return ( +
+ toggleExpand(item.key)} + /> + {hasChildren && isExpanded && ( +
+ {item.children!.map(child => ( + + ))} +
+ )} +
+ ); +} + /* ── Sub-components ─────────────────────────────────── */ function LayerTreeItem({ diff --git a/frontend/src/components/iran/AirportLayer.ts b/frontend/src/components/iran/AirportLayer.ts new file mode 100644 index 0000000..fdf9fb4 --- /dev/null +++ b/frontend/src/components/iran/AirportLayer.ts @@ -0,0 +1,151 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { middleEastAirports } from '../../data/airports'; +import type { Airport } from '../../data/airports'; + +// ─── US base detection ─────────────────────────────────────────────────────── + +const US_BASE_ICAOS = new Set([ + 'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL', +]); + +function isUSBase(airport: Airport): boolean { + return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao); +} + +// ─── Deduplication ─────────────────────────────────────────────────────────── + +const TYPE_PRIORITY: Record = { + military: 3, large: 2, medium: 1, small: 0, +}; + +function deduplicateByArea(airports: Airport[]): Airport[] { + const sorted = [...airports].sort((a, b) => { + const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0); + const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0); + return pb - pa; + }); + const kept: Airport[] = []; + for (const ap of sorted) { + const tooClose = kept.some( + k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8, + ); + if (!tooClose) kept.push(ap); + } + return kept; +} + +const DEDUPLICATED_AIRPORTS = deduplicateByArea(middleEastAirports); +export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length; + +// ─── Colors ────────────────────────────────────────────────────────────────── + +function getAirportColor(airport: Airport): string { + if (isUSBase(airport)) return '#3b82f6'; + if (airport.type === 'military') return '#ef4444'; + return '#f59e0b'; +} + +// ─── SVG generators ────────────────────────────────────────────────────────── + +function militaryPlaneSvg(color: string): string { + return ``; +} + +function civilPlaneSvg(color: string): string { + return ``; +} + +function airportSvg(airport: Airport): string { + const color = getAirportColor(airport); + const isMil = airport.type === 'military'; + const size = airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28; + const plane = isMil ? militaryPlaneSvg(color) : civilPlaneSvg(color); + return ` + + ${plane} + `; +} + +// ─── Module-level icon cache ───────────────────────────────────────────────── + +const airportIconCache = new Map(); + +function getAirportIconUrl(airport: Airport): string { + const isUS = isUSBase(airport); + const key = `${airport.type}-${isUS ? 'us' : 'std'}`; + if (!airportIconCache.has(key)) { + airportIconCache.set(key, svgToDataUri(airportSvg(airport))); + } + return airportIconCache.get(key)!; +} + +function getIconDimension(airport: Airport): number { + return airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28; +} + +// ─── Label color ───────────────────────────────────────────────────────────── + +function getAirportLabelColor(airport: Airport): [number, number, number, number] { + if (isUSBase(airport)) return [59, 130, 246, 255]; + if (airport.type === 'military') return [239, 68, 68, 255]; + return [245, 158, 11, 255]; +} + +// ─── Public interface ──────────────────────────────────────────────────────── + +export interface AirportLayerConfig { + visible: boolean; + sc: number; + onPick: (ap: Airport) => void; +} + +export function createIranAirportLayers(config: AirportLayerConfig): Layer[] { + if (!config.visible) return []; + + const { sc, onPick } = config; + + return [ + new IconLayer({ + id: 'iran-airport-icon', + data: DEDUPLICATED_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const dim = getIconDimension(d); + return { + url: getAirportIconUrl(d), + width: dim, + height: dim, + anchorX: dim / 2, + anchorY: dim / 2, + }; + }, + getSize: (d) => (d.type === 'large' ? 16 : d.type === 'small' ? 12 : 14) * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }), + new TextLayer({ + id: 'iran-airport-label', + data: DEDUPLICATED_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo ?? d.name, + getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => getAirportLabelColor(d), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ]; +} diff --git a/frontend/src/components/iran/AirportLayer.tsx b/frontend/src/components/iran/AirportLayer.tsx deleted file mode 100644 index 34a243b..0000000 --- a/frontend/src/components/iran/AirportLayer.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { memo, useMemo, useState } from 'react'; -import { Marker, Popup } from 'react-map-gl/maplibre'; -import type { Airport } from '../../data/airports'; - -const US_BASE_ICAOS = new Set([ - 'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL', -]); - -function isUSBase(airport: Airport): boolean { - return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao); -} - -const FLAG_EMOJI: Record = { - IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}', - AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}', - BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}', - TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}', - SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}', - DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}', -}; - -const TYPE_LABELS: Record = { - large: 'International Airport', medium: 'Airport', - small: 'Regional Airport', military: 'Military Airbase', -}; - -interface Props { airports: Airport[]; } - -const TYPE_PRIORITY: Record = { - military: 3, large: 2, medium: 1, small: 0, -}; - -// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small. -function deduplicateByArea(airports: Airport[]): Airport[] { - const sorted = [...airports].sort((a, b) => { - const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0); - const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0); - return pb - pa; - }); - const kept: Airport[] = []; - for (const ap of sorted) { - const tooClose = kept.some( - k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8, - ); - if (!tooClose) kept.push(ap); - } - return kept; -} - -export const AirportLayer = memo(function AirportLayer({ airports }: Props) { - const filtered = useMemo(() => deduplicateByArea(airports), [airports]); - return ( - <> - {filtered.map(ap => ( - - ))} - - ); -}); - -function AirportMarker({ airport }: { airport: Airport }) { - const [showPopup, setShowPopup] = useState(false); - const isMil = airport.type === 'military'; - const isUS = isUSBase(airport); - const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b'; - const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16; - const flag = FLAG_EMOJI[airport.country] || ''; - - // Single circle with airplane inside (plane shifted down to center in circle) - const plane = isMil - ? - : ; - const icon = ( - - - {plane} - - ); - - return ( - <> - -
{ e.stopPropagation(); setShowPopup(true); }}> - {icon} -
-
- {showPopup && ( - setShowPopup(false)} closeOnClick={false} - anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup"> -
-
- {isUS ? {'\u{1F1FA}\u{1F1F8}'} - : flag ? {flag} : null} - {airport.name} -
- {airport.nameKo && ( -
{airport.nameKo}
- )} -
- - {isUS ? 'US Military Base' : TYPE_LABELS[airport.type]} - -
-
- {airport.iata &&
IATA : {airport.iata}
} -
ICAO : {airport.icao}
- {airport.city &&
City : {airport.city}
} -
Country : {airport.country}
-
-
- {airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'} -
- {airport.iata && ( - - )} -
-
- )} - - ); -} diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index a1a5d63..f8da0ef 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -1,5 +1,9 @@ import { useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; +import { IRAN_OIL_COUNT } from './createIranOilLayers'; +import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers'; +import { ME_FACILITY_COUNT } from './createMEFacilityLayers'; +import { ME_ENERGY_HAZARD_FACILITIES } from '../../data/meEnergyHazardFacilities'; import { ReplayMap } from './ReplayMap'; import type { FlyToTarget } from './ReplayMap'; import { GlobeMap } from './GlobeMap'; @@ -56,7 +60,7 @@ const INITIAL_LAYERS: LayerVisibility = { meFacilities: true, militaryOnly: false, overseasUS: false, - overseasUK: false, + overseasIsrael: false, overseasIran: false, overseasUAE: false, overseasSaudi: false, @@ -113,6 +117,8 @@ const IranDashboard = ({ setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); + const meCountByCountry = (ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length; + // 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트 const headerSlot = document.getElementById('dashboard-header-slot'); const countsSlot = document.getElementById('dashboard-counts-slot'); @@ -216,22 +222,22 @@ const IranDashboard = ({ extraLayers={[ { key: 'events', label: t('layers.events'), color: '#a855f7' }, { key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length }, - { key: 'airports', label: t('layers.airports'), color: '#f59e0b' }, - { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' }, - { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 }, + { key: 'airports', label: t('layers.airports'), color: '#f59e0b', count: IRAN_AIRPORT_COUNT }, + { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT }, + { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, ]} overseasItems={[ - { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' }, - { key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' }, - { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' }, - { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' }, - { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' }, - { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' }, - { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' }, - { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' }, - { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' }, - { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' }, + { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') }, + { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') }, + { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') }, + { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') }, + { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') }, + { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') }, + { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') }, + { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') }, + { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') }, + { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') }, ]} hiddenAcCategories={hiddenAcCategories} hiddenShipCategories={hiddenShipCategories} diff --git a/frontend/src/components/iran/MEEnergyHazardLayer.tsx b/frontend/src/components/iran/MEEnergyHazardLayer.tsx new file mode 100644 index 0000000..8e8d542 --- /dev/null +++ b/frontend/src/components/iran/MEEnergyHazardLayer.tsx @@ -0,0 +1,219 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { + ME_ENERGY_HAZARD_FACILITIES, + SUB_TYPE_META, + layerKeyToSubType, + layerKeyToCountry, + type EnergyHazardFacility, + type FacilitySubType, +} from '../../data/meEnergyHazardFacilities'; + +// LayerVisibility overseas key → countryKey mapping +const COUNTRY_KEY_TO_LAYER_KEY: Record = { + us: 'overseasUS', + ir: 'overseasIran', + ae: 'overseasUAE', + sa: 'overseasSaudi', + om: 'overseasOman', + qa: 'overseasQatar', + kw: 'overseasKuwait', + iq: 'overseasIraq', + bh: 'overseasBahrain', + // il (Israel) is shown when meFacilities is true (no dedicated overseas key) + il: 'meFacilities', +}; + +function isFacilityVisible(f: EnergyHazardFacility, layers: Record): boolean { + const countryLayerKey = COUNTRY_KEY_TO_LAYER_KEY[f.countryKey]; + if (!countryLayerKey || !layers[countryLayerKey]) return false; + + // Check sub-type toggle if present, otherwise fall through to country-level toggle + // Sub-type keys: e.g. "irPower", "ilNuclear", "usOilTank" + const subTypeKey = f.countryKey + capitalizeFirst(f.subType.replace('_', '')); + if (subTypeKey in layers) return !!layers[subTypeKey]; + + // Check category-level parent key: e.g. "irEnergy", "usHazard" + const categoryKey = f.countryKey + capitalizeFirst(f.category); + if (categoryKey in layers) return !!layers[categoryKey]; + + // Fall back to country-level toggle + return true; +} + +function capitalizeFirst(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +// Pre-build layer-key → subType entries from layerKeyToSubType/layerKeyToCountry +// for reference — the actual filter uses the above isFacilityVisible logic. +// Exported for re-use elsewhere if needed. +export { layerKeyToSubType, layerKeyToCountry }; + +export interface MELayerConfig { + layers: Record; + sc: number; + onPick: (facility: EnergyHazardFacility) => void; +} + +function hexToRgba(hex: string, alpha = 255): [number, 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, alpha]; +} + +// ─── SVG icon functions ──────────────────────────────────────────────────────── + +function powerSvg(color: string, size: number): string { + return ` + + + `; +} + +function windSvg(color: string, size: number): string { + return ` + + + + + + + + + `; +} + +function nuclearSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function thermalSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function petrochemSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function lngSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function oilTankSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function hazPortSvg(color: string, size: number): string { + return ` + + + + + `; +} + +const SUB_TYPE_SVG_FN: Record string> = { + power: powerSvg, + wind: windSvg, + nuclear: nuclearSvg, + thermal: thermalSvg, + petrochem: petrochemSvg, + lng: lngSvg, + oil_tank: oilTankSvg, + haz_port: hazPortSvg, +}; + +const iconCache = new Map(); + +function getIconUrl(subType: FacilitySubType): string { + if (!iconCache.has(subType)) { + const color = SUB_TYPE_META[subType].color; + iconCache.set(subType, svgToDataUri(SUB_TYPE_SVG_FN[subType](color, 64))); + } + return iconCache.get(subType)!; +} + +export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { + const { layers, sc, onPick } = config; + + const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f => + isFacilityVisible(f, layers), + ); + + if (visibleFacilities.length === 0) return []; + + const iconLayer = new IconLayer({ + id: 'me-energy-hazard-icon', + data: visibleFacilities, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: (d) => (d.category === 'hazard' ? 20 : 18) * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }); + + const labelLayer = new TextLayer({ + id: 'me-energy-hazard-label', + data: visibleFacilities, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + }); + + return [iconLayer, labelLayer]; +} diff --git a/frontend/src/components/iran/MEFacilityLayer.ts b/frontend/src/components/iran/MEFacilityLayer.ts new file mode 100644 index 0000000..626e529 --- /dev/null +++ b/frontend/src/components/iran/MEFacilityLayer.ts @@ -0,0 +1,179 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { ME_FACILITIES } from '../../data/middleEastFacilities'; +import type { MEFacility } from '../../data/middleEastFacilities'; + +export const ME_FACILITY_COUNT = ME_FACILITIES.length; + +// ─── Type colors ────────────────────────────────────────────────────────────── + +const TYPE_COLORS: Record = { + naval: '#3b82f6', + military_hq: '#ef4444', + missile: '#dc2626', + intelligence: '#8b5cf6', + government: '#f59e0b', + radar: '#06b6d4', +}; + +// ─── SVG generators ────────────────────────────────────────────────────────── + +// naval: anchor symbol +function navalSvg(color: string): string { + return ` + + + + + + + + `; +} + +// military_hq: star symbol +function militaryHqSvg(color: string): string { + return ` + + + `; +} + +// missile: upward arrow / rocket shape +function missileSvg(color: string): string { + return ` + + + + + + + + `; +} + +// intelligence: magnifying glass +function intelligenceSvg(color: string): string { + return ` + + + + + `; +} + +// government: pillars / building +function governmentSvg(color: string): string { + return ` + + + + + + + + + `; +} + +// radar: radio waves / dish +function radarSvg(color: string): string { + return ` + + + + + + + + + + `; +} + +function buildMESvg(type: MEFacility['type'], color: string): string { + switch (type) { + case 'naval': return navalSvg(color); + case 'military_hq': return militaryHqSvg(color); + case 'missile': return missileSvg(color); + case 'intelligence': return intelligenceSvg(color); + case 'government': return governmentSvg(color); + case 'radar': return radarSvg(color); + } +} + +// ─── Module-level icon cache ───────────────────────────────────────────────── + +const meIconCache = new Map(); + +function getMEIconUrl(type: MEFacility['type']): string { + if (!meIconCache.has(type)) { + meIconCache.set(type, svgToDataUri(buildMESvg(type, TYPE_COLORS[type]))); + } + return meIconCache.get(type)!; +} + +// ─── Label color ───────────────────────────────────────────────────────────── + +function getMELabelColor(type: MEFacility['type']): [number, number, number, number] { + const hex = TYPE_COLORS[type]; + 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, 255]; +} + +// ─── Public interface ──────────────────────────────────────────────────────── + +export interface MEFacilityLayerConfig { + visible: boolean; + sc: number; + onPick: (f: MEFacility) => void; +} + +export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { + if (!config.visible) return []; + + const { sc, onPick } = config; + + return [ + new IconLayer({ + id: 'me-facility-icon', + data: ME_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ + url: getMEIconUrl(d.type), + width: 36, + height: 36, + anchorX: 18, + anchorY: 18, + }), + getSize: 16 * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }), + new TextLayer({ + id: 'me-facility-label', + data: ME_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), + getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => getMELabelColor(d.type), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ]; +} diff --git a/frontend/src/components/iran/MEFacilityLayer.tsx b/frontend/src/components/iran/MEFacilityLayer.tsx deleted file mode 100644 index b6ca041..0000000 --- a/frontend/src/components/iran/MEFacilityLayer.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { memo, useState } from 'react'; -import { Marker, Popup } from 'react-map-gl/maplibre'; -import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities'; -import type { MEFacility } from '../../data/middleEastFacilities'; - -export const MEFacilityLayer = memo(function MEFacilityLayer() { - const [selected, setSelected] = useState(null); - - return ( - <> - {ME_FACILITIES.map(f => { - const meta = ME_FACILITY_TYPE_META[f.type]; - return ( - { e.originalEvent.stopPropagation(); setSelected(f); }}> -
-
- {meta.icon} -
-
- {f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo} -
-
-
- ); - })} - - {selected && (() => { - const meta = ME_FACILITY_TYPE_META[selected.type]; - return ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="320px" className="gl-popup"> -
-
- {selected.flag} - {meta.icon} {selected.nameKo} -
-
- - {meta.label} - - - {selected.country} - -
-
- {selected.description} -
-
- {selected.name} -
-
- {selected.lat.toFixed(4)}°{selected.lat >= 0 ? 'N' : 'S'}, {selected.lng.toFixed(4)}°E -
-
-
- ); - })()} - - ); -}); diff --git a/frontend/src/components/iran/OilFacilityLayer.ts b/frontend/src/components/iran/OilFacilityLayer.ts new file mode 100644 index 0000000..1ab4d1f --- /dev/null +++ b/frontend/src/components/iran/OilFacilityLayer.ts @@ -0,0 +1,252 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { iranOilFacilities } from '../../data/oilFacilities'; +import type { OilFacility, OilFacilityType } from '../../types'; + +export const IRAN_OIL_COUNT = iranOilFacilities.length; + +// ─── Type colors ────────────────────────────────────────────────────────────── + +const TYPE_COLORS: Record = { + refinery: '#f59e0b', + oilfield: '#10b981', + gasfield: '#6366f1', + terminal: '#ec4899', + petrochemical: '#8b5cf6', + desalination: '#06b6d4', +}; + +// ─── SVG generators ────────────────────────────────────────────────────────── + +function damageOverlaySvg(): string { + return ` + + `; +} + +function plannedOverlaySvg(): string { + return ` + + + + `; +} + +function refinerySvg(color: string, damaged: boolean, planned: boolean): string { + const sc = damaged ? '#ff0000' : color; + return ` + + + + + + + + + + + + + + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function oilfieldSvg(color: string, damaged: boolean, planned: boolean): string { + const sc = damaged ? '#ff0000' : color; + return ` + + + + + + + + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function gasfieldSvg(color: string, damaged: boolean, planned: boolean): string { + return ` + + + + + + + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function terminalSvg(color: string, damaged: boolean, planned: boolean): string { + const sc = damaged ? '#ff0000' : color; + return ` + + + + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function petrochemSvg(color: string, damaged: boolean, planned: boolean): string { + const sc = damaged ? '#ff0000' : '#fff'; + return ` + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function desalSvg(color: string, damaged: boolean, planned: boolean): string { + const sc = damaged ? '#ff0000' : color; + return ` + + + + + + + + + + + + + + ${damaged ? damageOverlaySvg() : ''} + ${planned ? plannedOverlaySvg() : ''} + `; +} + +function buildSvg(type: OilFacilityType, color: string, damaged: boolean, planned: boolean): string { + switch (type) { + case 'refinery': return refinerySvg(color, damaged, planned); + case 'oilfield': return oilfieldSvg(color, damaged, planned); + case 'gasfield': return gasfieldSvg(color, damaged, planned); + case 'terminal': return terminalSvg(color, damaged, planned); + case 'petrochemical': return petrochemSvg(color, damaged, planned); + case 'desalination': return desalSvg(color, damaged, planned); + } +} + +// ─── Module-level icon cache ───────────────────────────────────────────────── + +const oilIconCache = new Map(); + +function getOilIconUrl(type: OilFacilityType, damaged: boolean, planned: boolean): string { + const key = `${type}-${damaged ? 'd' : 'n'}-${planned ? 'p' : 'n'}`; + if (!oilIconCache.has(key)) { + const color = TYPE_COLORS[type]; + oilIconCache.set(key, svgToDataUri(buildSvg(type, color, damaged, planned))); + } + return oilIconCache.get(key)!; +} + +// ─── Label color ───────────────────────────────────────────────────────────── + +function getLabelColor(type: OilFacilityType, damaged: boolean, planned: boolean): [number, number, number, number] { + if (damaged) return [255, 0, 0, 255]; + if (planned) return [255, 102, 0, 255]; + const hex = TYPE_COLORS[type]; + 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, 255]; +} + +// ─── Public interface ──────────────────────────────────────────────────────── + +export interface OilLayerConfig { + visible: boolean; + sc: number; + currentTime: number; + onPick: (f: OilFacility) => void; +} + +export function createIranOilLayers(config: OilLayerConfig): Layer[] { + if (!config.visible) return []; + + const { sc, currentTime, onPick } = config; + + return [ + new IconLayer({ + id: 'iran-oil-icon', + data: iranOilFacilities, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt); + const isPlanned = !!d.planned && !isDamaged; + return { + url: getOilIconUrl(d.type, isDamaged, isPlanned), + width: 36, + height: 36, + anchorX: 18, + anchorY: 18, + }; + }, + getSize: 18 * sc, + updateTriggers: { getSize: [sc], getIcon: [currentTime] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }), + new TextLayer({ + id: 'iran-oil-label', + data: iranOilFacilities, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo, + getSize: 9 * sc, + updateTriggers: { getSize: [sc], getColor: [currentTime] }, + getColor: (d) => { + const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt); + const isPlanned = !!d.planned && !isDamaged; + return getLabelColor(d.type, isDamaged, isPlanned); + }, + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ]; +} diff --git a/frontend/src/components/iran/OilFacilityLayer.tsx b/frontend/src/components/iran/OilFacilityLayer.tsx deleted file mode 100644 index 00c52c5..0000000 --- a/frontend/src/components/iran/OilFacilityLayer.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { memo, useState } from 'react'; -import { Marker, Popup } from 'react-map-gl/maplibre'; -import { useTranslation } from 'react-i18next'; -import type { OilFacility, OilFacilityType } from '../../types'; - -interface Props { - facilities: OilFacility[]; - currentTime: number; -} - -const TYPE_COLORS: Record = { - refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', - terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', -}; - -function formatNumber(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; - return String(n); -} - -function getTooltipLabel(f: OilFacility): string { - if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`; - if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`; - if (f.reservesBbl) return `${f.reservesBbl}B bbl`; - if (f.reservesTcf) return `${f.reservesTcf} Tcf`; - if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`; - return ''; -} - - -// Planned strike targeting ring (SVG 내부 — 위치 정확도) -function PlannedOverlay() { - return ( - <> - - - - - {/* Crosshair lines */} - - - - - - ); -} - -// Shared damage overlay (X mark + circle) -function DamageOverlay() { - return ( - <> - - - - - ); -} - -// SVG icon renderers (JSX versions) -function RefineryIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - const sc = damaged ? '#ff0000' : color; - return ( - - - - - - - - - - - - - - - - - - - - - {damaged && } - {planned && } - - ); -} - -function OilFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - const sc = damaged ? '#ff0000' : color; - return ( - - - - - - - - - - - - - - - {damaged && } - {planned && } - - ); -} - -function GasFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - return ( - - - - - - - - - - - - - - {damaged && } - {planned && } - - ); -} - -function TerminalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - return ( - - - - - - - - - - - {damaged && } - {planned && } - - ); -} - -function PetrochemIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - return ( - - - - - - - - {damaged && } - {planned && } - - ); -} - -function DesalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { - const sc = damaged ? '#ff0000' : color; - return ( - - - - - - - - - - - - - - - {damaged && } - {planned && } - - ); -} - -// 모든 아이콘을 36x36 고정 크기로 렌더링 (anchor="center" 정렬용) -const ICON_RENDER_SIZE = 36; - -function FacilityIconSvg({ facility, damaged, planned }: { facility: OilFacility; damaged: boolean; planned: boolean }) { - const color = TYPE_COLORS[facility.type]; - const props = { size: ICON_RENDER_SIZE, color, damaged, planned }; - switch (facility.type) { - case 'refinery': return ; - case 'oilfield': return ; - case 'gasfield': return ; - case 'terminal': return ; - case 'petrochemical': return ; - case 'desalination': return ; - } -} - -export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) { - return ( - <> - {facilities.map(f => ( - - ))} - - ); -}); - -function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) { - const { t } = useTranslation('ships'); - const [showPopup, setShowPopup] = useState(false); - const color = TYPE_COLORS[facility.type]; - const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt); - const isPlanned = !!facility.planned && !isDamaged; - const stat = getTooltipLabel(facility); - - return ( - <> - -
{ e.stopPropagation(); setShowPopup(true); }} - > - - {/* Label */} -
- {isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo} - {stat && {stat}} -
-
-
- {showPopup && ( - setShowPopup(false)} closeOnClick={false} - anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
- {t(`facility.type.${facility.type}`)} - {isDamaged && ( - - {t('facility.damaged')} - - )} - {isPlanned && ( - - {t('facility.plannedStrike')} - - )} -
-
{facility.nameKo}
-
{facility.name}
-
- {facility.capacityBpd != null && ( - <>{t('facility.production')} - {formatNumber(facility.capacityBpd)} bpd - )} - {facility.capacityMgd != null && ( - <>{t('facility.desalProduction')} - {formatNumber(facility.capacityMgd)} MGD - )} - {facility.capacityMcfd != null && ( - <>{t('facility.gasProduction')} - {formatNumber(facility.capacityMcfd)} Mcf/d - )} - {facility.reservesBbl != null && ( - <>{t('facility.reserveOil')} - {facility.reservesBbl}B {t('facility.barrels')} - )} - {facility.reservesTcf != null && ( - <>{t('facility.reserveGas')} - {facility.reservesTcf} Tcf - )} - {facility.operator && ( - <>{t('facility.operator')} - {facility.operator} - )} -
- {facility.description && ( -

{facility.description}

- )} - {isPlanned && facility.plannedLabel && ( -
- {facility.plannedLabel} -
- )} -
- {facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E -
-
-
- )} - - ); -} diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index cdf25c1..f1807b9 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -7,11 +7,16 @@ import { SatelliteLayer } from '../layers/SatelliteLayer'; import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; -import { OilFacilityLayer } from './OilFacilityLayer'; -import { AirportLayer } from './AirportLayer'; -import { MEFacilityLayer } from './MEFacilityLayer'; -import { iranOilFacilities } from '../../data/oilFacilities'; -import { middleEastAirports } from '../../data/airports'; +import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; +import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; +import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; +import { createIranOilLayers } from './createIranOilLayers'; +import type { OilFacility } from './createIranOilLayers'; +import { createIranAirportLayers } from './createIranAirportLayers'; +import type { Airport } from './createIranAirportLayers'; +import { createMEFacilityLayers } from './createMEFacilityLayers'; +import type { MEFacility } from './createMEFacilityLayers'; import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -111,10 +116,48 @@ const EVENT_RADIUS: Record = { osint: 8, }; +type IranPickedFacility = + | { kind: 'oil'; data: OilFacility } + | { kind: 'airport'; data: Airport } + | { kind: 'meFacility'; data: MEFacility }; + export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); + const [mePickedFacility, setMePickedFacility] = useState(null); + const [iranPickedFacility, setIranPickedFacility] = useState(null); + const [zoomLevel, setZoomLevel] = useState(5); + const zoomRef = useRef(5); + + const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { + const z = Math.floor(e.viewState.zoom); + if (z !== zoomRef.current) { + zoomRef.current = z; + setZoomLevel(z); + } + }, []); + + const zoomScale = useMemo(() => { + if (zoomLevel <= 4) return 0.8; + if (zoomLevel <= 5) return 0.9; + if (zoomLevel <= 6) return 1.0; + if (zoomLevel <= 7) return 1.2; + if (zoomLevel <= 8) return 1.5; + if (zoomLevel <= 9) return 1.8; + if (zoomLevel <= 10) return 2.2; + if (zoomLevel <= 11) return 2.5; + if (zoomLevel <= 12) return 2.8; + if (zoomLevel <= 13) return 3.5; + return 4.2; + }, [zoomLevel]); + + const iranDeckLayers = useMemo(() => [ + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), + ], [layers, zoomScale]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -187,6 +230,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }} style={{ width: '100%', height: '100%' }} mapStyle={MAP_STYLE} + onZoom={handleZoom} > @@ -432,9 +476,158 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la {layers.ships && } {layers.ships && } {seismicMarker && } - {layers.airports && } - {layers.oilFacilities && } - {layers.meFacilities && } + + + + {mePickedFacility && (() => { + const meta = SUB_TYPE_META[mePickedFacility.subType]; + return ( + setMePickedFacility(null)} + closeOnClick={false} + anchor="bottom" + maxWidth="320px" + className="gl-popup" + > +
+
+ {meta.icon} {mePickedFacility.nameKo} +
+
+ + {meta.label} + + + {mePickedFacility.country} + + {mePickedFacility.capacityMW !== undefined && ( + + {mePickedFacility.capacityMW.toLocaleString()} MW + + )} +
+
+ {mePickedFacility.description} +
+
+ {mePickedFacility.name} +
+
+ {mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E +
+
+
+ ); + })()} + + {iranPickedFacility && (() => { + const { kind, data } = iranPickedFacility; + if (kind === 'oil') { + const OIL_TYPE_COLORS: Record = { + refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', + terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', + }; + const color = OIL_TYPE_COLORS[data.type] ?? '#888'; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.nameKo} +
+
+ + {data.type} + + {data.operator && ( + + {data.operator} + + )} +
+ {data.description && ( +
+ {data.description} +
+ )} +
+ {data.name} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E +
+
+
+ ); + } + if (kind === 'airport') { + const isMil = data.type === 'military'; + const color = isMil ? '#ef4444' : '#f59e0b'; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.nameKo ?? data.name} +
+
+ + {data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'} + + + {data.country} + +
+
+ {data.iata && <>IATA{data.iata}} + ICAO{data.icao} + {data.city && <>City{data.city}} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'} +
+
+
+ ); + } + if (kind === 'meFacility') { + const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.flag} + {meta.icon} {data.nameKo} +
+
+ + {meta.label} + + + {data.country} + +
+
+ {data.description} +
+
+ {data.name} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E +
+
+
+ ); + } + return null; + })()} ); } diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index eb2bc9a..1aacc2c 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useRef, useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -7,11 +7,16 @@ import { SatelliteLayer } from '../layers/SatelliteLayer'; import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; -import { OilFacilityLayer } from './OilFacilityLayer'; -import { AirportLayer } from './AirportLayer'; -import { MEFacilityLayer } from './MEFacilityLayer'; -import { iranOilFacilities } from '../../data/oilFacilities'; -import { middleEastAirports } from '../../data/airports'; +import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; +import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; +import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; +import { createIranOilLayers } from './createIranOilLayers'; +import type { OilFacility } from './createIranOilLayers'; +import { createIranAirportLayers } from './createIranAirportLayers'; +import type { Airport } from './createIranAirportLayers'; +import { createMEFacilityLayers } from './createMEFacilityLayers'; +import type { MEFacility } from './createMEFacilityLayers'; import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; import maplibregl from 'maplibre-gl'; @@ -94,10 +99,48 @@ const EVENT_RADIUS: Record = { osint: 8, }; +type IranPickedFacility = + | { kind: 'oil'; data: OilFacility } + | { kind: 'airport'; data: Airport } + | { kind: 'meFacility'; data: MEFacility }; + export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); + const [mePickedFacility, setMePickedFacility] = useState(null); + const [iranPickedFacility, setIranPickedFacility] = useState(null); + const [zoomLevel, setZoomLevel] = useState(5); + const zoomRef = useRef(5); + + const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { + const z = Math.floor(e.viewState.zoom); + if (z !== zoomRef.current) { + zoomRef.current = z; + setZoomLevel(z); + } + }, []); + + const zoomScale = useMemo(() => { + if (zoomLevel <= 4) return 0.8; + if (zoomLevel <= 5) return 0.9; + if (zoomLevel <= 6) return 1.0; + if (zoomLevel <= 7) return 1.2; + if (zoomLevel <= 8) return 1.5; + if (zoomLevel <= 9) return 1.8; + if (zoomLevel <= 10) return 2.2; + if (zoomLevel <= 11) return 2.5; + if (zoomLevel <= 12) return 2.8; + if (zoomLevel <= 13) return 3.5; + return 4.2; + }, [zoomLevel]); + + const iranDeckLayers = useMemo(() => [ + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), + ], [layers, zoomScale]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -133,8 +176,53 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, style={{ width: '100%', height: '100%' }} mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification} attributionControl={false} + onZoom={handleZoom} > + + + {mePickedFacility && (() => { + const meta = SUB_TYPE_META[mePickedFacility.subType]; + return ( + setMePickedFacility(null)} + closeOnClick={false} + anchor="bottom" + maxWidth="320px" + className="gl-popup" + > +
+
+ {meta.icon} {mePickedFacility.nameKo} +
+
+ + {meta.label} + + + {mePickedFacility.country} + + {mePickedFacility.capacityMW !== undefined && ( + + {mePickedFacility.capacityMW.toLocaleString()} MW + + )} +
+
+ {mePickedFacility.description} +
+
+ {mePickedFacility.name} +
+
+ {mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E +
+
+
+ ); + })()} {/* Korean country labels */} @@ -264,15 +352,119 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, )} + {iranPickedFacility && (() => { + const { kind, data } = iranPickedFacility; + if (kind === 'oil') { + const OIL_TYPE_COLORS: Record = { + refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', + terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', + }; + const color = OIL_TYPE_COLORS[data.type] ?? '#888'; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.nameKo} +
+
+ + {data.type} + + {data.operator && ( + + {data.operator} + + )} +
+ {data.description && ( +
+ {data.description} +
+ )} +
+ {data.name} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E +
+
+
+ ); + } + if (kind === 'airport') { + const isMil = data.type === 'military'; + const color = isMil ? '#ef4444' : '#f59e0b'; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.nameKo ?? data.name} +
+
+ + {data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'} + + + {data.country} + +
+
+ {data.iata && <>IATA{data.iata}} + ICAO{data.icao} + {data.city && <>City{data.city}} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'} +
+
+
+ ); + } + if (kind === 'meFacility') { + const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; + return ( + setIranPickedFacility(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {data.flag} + {meta.icon} {data.nameKo} +
+
+ + {meta.label} + + + {data.country} + +
+
+ {data.description} +
+
+ {data.name} +
+
+ {data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E +
+
+
+ ); + } + return null; + })()} + {/* Overlay layers */} {layers.aircraft && } {layers.satellites && } {layers.ships && } {seismicMarker && } - {layers.oilFacilities && } - {layers.airports && } - {layers.meFacilities && } ); } diff --git a/frontend/src/components/iran/createIranAirportLayers.ts b/frontend/src/components/iran/createIranAirportLayers.ts new file mode 100644 index 0000000..e668201 --- /dev/null +++ b/frontend/src/components/iran/createIranAirportLayers.ts @@ -0,0 +1,103 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { middleEastAirports } from '../../data/airports'; +import type { Airport } from '../../data/airports'; + +export { type Airport }; + +export const IRAN_AIRPORT_COUNT = middleEastAirports.length; + +const US_BASE_ICAOS = new Set([ + 'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL', +]); + +function getAirportColor(airport: Airport): string { + const isMil = airport.type === 'military'; + const isUS = isMil && US_BASE_ICAOS.has(airport.icao); + if (isUS) return '#3b82f6'; + if (isMil) return '#ef4444'; + if (airport.type === 'international') return '#f59e0b'; + return '#7c8aaa'; +} + +function airportSvg(color: string, size: number): string { + return ` + + + `; +} + +const iconCache = new Map(); + +function getIconUrl(airport: Airport): string { + const color = getAirportColor(airport); + const size = airport.type === 'military' && US_BASE_ICAOS.has(airport.icao) ? 48 : 40; + const key = `${color}-${size}`; + if (!iconCache.has(key)) { + iconCache.set(key, svgToDataUri(airportSvg(color, size))); + } + return iconCache.get(key)!; +} + +function hexToRgba(hex: string, alpha = 255): [number, 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, alpha]; +} + +export interface IranAirportLayerConfig { + visible: boolean; + sc: number; + onPick: (airport: Airport) => void; +} + +export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] { + const { visible, sc, onPick } = config; + if (!visible) return []; + + const iconLayer = new IconLayer({ + id: 'iran-airport-icon', + data: middleEastAirports, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const isMilUS = d.type === 'military' && US_BASE_ICAOS.has(d.icao); + const sz = isMilUS ? 48 : 40; + return { url: getIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.type === 'military' && US_BASE_ICAOS.has(d.icao) ? 20 : 16) * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }); + + const labelLayer = new TextLayer({ + id: 'iran-airport-label', + data: middleEastAirports, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => { + const nameKo = d.nameKo ?? d.name; + return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo; + }, + getSize: 11 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => hexToRgba(getAirportColor(d)), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 600, + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + }); + + return [iconLayer, labelLayer]; +} diff --git a/frontend/src/components/iran/createIranOilLayers.ts b/frontend/src/components/iran/createIranOilLayers.ts new file mode 100644 index 0000000..a42a137 --- /dev/null +++ b/frontend/src/components/iran/createIranOilLayers.ts @@ -0,0 +1,153 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { iranOilFacilities } from '../../data/oilFacilities'; +import type { OilFacility, OilFacilityType } from '../../types'; + +export { type OilFacility }; + +export const IRAN_OIL_COUNT = iranOilFacilities.length; + +const TYPE_COLORS: Record = { + refinery: '#f59e0b', + oilfield: '#10b981', + gasfield: '#6366f1', + terminal: '#ec4899', + petrochemical: '#8b5cf6', + desalination: '#06b6d4', +}; + +function refinerySvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function oilfieldSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function gasfieldSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function terminalSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function petrochemSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function desalinationSvg(color: string, size: number): string { + return ` + + + + `; +} + +type SvgFn = (color: string, size: number) => string; + +const TYPE_SVG_FN: Record = { + refinery: refinerySvg, + oilfield: oilfieldSvg, + gasfield: gasfieldSvg, + terminal: terminalSvg, + petrochemical: petrochemSvg, + desalination: desalinationSvg, +}; + +const iconCache = new Map(); + +function getIconUrl(type: OilFacilityType): string { + if (!iconCache.has(type)) { + const color = TYPE_COLORS[type]; + iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64))); + } + return iconCache.get(type)!; +} + +function hexToRgba(hex: string, alpha = 255): [number, 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, alpha]; +} + +export interface IranOilLayerConfig { + visible: boolean; + sc: number; + onPick: (facility: OilFacility) => void; +} + +export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { + const { visible, sc, onPick } = config; + if (!visible) return []; + + const iconLayer = new IconLayer({ + id: 'iran-oil-icon', + data: iranOilFacilities, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 18 * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }); + + const labelLayer = new TextLayer({ + id: 'iran-oil-label', + data: iranOilFacilities, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo, + getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => hexToRgba(TYPE_COLORS[d.type]), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 600, + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + }); + + return [iconLayer, labelLayer]; +} diff --git a/frontend/src/components/iran/createMEFacilityLayers.ts b/frontend/src/components/iran/createMEFacilityLayers.ts new file mode 100644 index 0000000..c7a01d9 --- /dev/null +++ b/frontend/src/components/iran/createMEFacilityLayers.ts @@ -0,0 +1,148 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities'; +import type { MEFacility } from '../../data/middleEastFacilities'; + +export { type MEFacility }; + +export const ME_FACILITY_COUNT = ME_FACILITIES.length; + +function hexToRgba(hex: string, alpha = 255): [number, 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, alpha]; +} + +function navalSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function militaryHqSvg(color: string, size: number): string { + return ` + + + `; +} + +function missileSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function intelligenceSvg(color: string, size: number): string { + return ` + + + + + `; +} + +function governmentSvg(color: string, size: number): string { + return ` + + + + + + + + + `; +} + +function radarSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +type MEFacilityType = MEFacility['type']; + +type SvgFn = (color: string, size: number) => string; + +const TYPE_SVG_FN: Record = { + naval: navalSvg, + military_hq: militaryHqSvg, + missile: missileSvg, + intelligence: intelligenceSvg, + government: governmentSvg, + radar: radarSvg, +}; + +const iconCache = new Map(); + +function getIconUrl(type: MEFacilityType): string { + if (!iconCache.has(type)) { + const color = ME_FACILITY_TYPE_META[type].color; + iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64))); + } + return iconCache.get(type)!; +} + +export interface MEFacilityLayerConfig { + visible: boolean; + sc: number; + onPick: (facility: MEFacility) => void; +} + +export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { + const { visible, sc, onPick } = config; + if (!visible) return []; + + const iconLayer = new IconLayer({ + id: 'me-facility-icon', + data: ME_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 18 * sc, + updateTriggers: { getSize: [sc] }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick(info.object); + return true; + }, + }); + + const labelLayer = new TextLayer({ + id: 'me-facility-label', + data: ME_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, + getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + }); + + return [iconLayer, labelLayer]; +} diff --git a/frontend/src/components/korea/AiChatPanel.tsx b/frontend/src/components/korea/AiChatPanel.tsx new file mode 100644 index 0000000..611f137 --- /dev/null +++ b/frontend/src/components/korea/AiChatPanel.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { Ship } from '../../types'; + +interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: number; +} + +interface Props { + ships: Ship[]; + koreanShipCount: number; + chineseShipCount: number; + totalShipCount: number; +} + +// TODO: Python FastAPI 기반 해양분석 AI API로 전환 예정 +const AI_CHAT_URL = '/api/kcg/ai/chat'; + +function buildSystemPrompt(props: Props): string { + const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props; + + // 선박 유형별 통계 + const byType: Record = {}; + const byFlag: Record = {}; + ships.forEach(s => { + byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1; + byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; + }); + + // 중국 어선 통계 + const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30')); + const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5); + + return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다. +현재 실시간 해양 모니터링 데이터를 기반으로 분석을 제공합니다. + +## 현재 해양 상황 요약 +- 전체 선박: ${totalShipCount}척 +- 한국 선박: ${koreanShipCount}척 +- 중국 선박: ${chineseShipCount}척 +- 중국 어선: ${cnFishing.length}척 (조업 추정: ${cnFishingOperating.length}척) + +## 선박 유형별 현황 +${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}척`).join('\n')} + +## 국적별 현황 (상위) +${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}척`).join('\n')} + +## 한중어업협정 핵심 +- 중국 허가어선 906척 (PT 저인망 323쌍, GN 유자망 200척, PS 위망 16척, OT 1척식 13척, FC 운반선 31척) +- 특정어업수역 I~IV에서만 조업 허가 +- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31 +- 다크베셀(AIS 차단) 감시 필수 + +## 응답 규칙 +- 한국어로 답변 +- 간결하고 분석적으로 +- 데이터 기반 답변 우선 +- 불법조업 의심 시 근거 제시 +- 필요시 조치 권고 포함`; +} + +export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) { + const [isOpen, setIsOpen] = useState(true); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + useEffect(() => { + if (isOpen) inputRef.current?.focus(); + }, [isOpen]); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() }; + setMessages(prev => [...prev, userMsg]); + setInput(''); + setIsLoading(true); + + try { + const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount }); + const apiMessages = [ + { role: 'system', content: systemPrompt }, + ...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })), + { role: 'user', content: userMsg.content }, + ]; + + const res = await fetch(AI_CHAT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'qwen2.5:7b', + messages: apiMessages, + stream: false, + options: { temperature: 0.3, num_predict: 1024 }, + }), + }); + + if (!res.ok) throw new Error(`Ollama error: ${res.status}`); + const data = await res.json(); + const assistantMsg: ChatMessage = { + role: 'assistant', + content: data.message?.content || '응답을 생성할 수 없습니다.', + timestamp: Date.now(), + }; + setMessages(prev => [...prev, assistantMsg]); + } catch (err) { + setMessages(prev => [...prev, { + role: 'assistant', + content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`, + timestamp: Date.now(), + }]); + } finally { + setIsLoading(false); + } + }, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]); + + const quickQuestions = [ + '현재 해양 상황을 요약해줘', + '중국어선 불법조업 의심 분석해줘', + '서해 위험도를 평가해줘', + '다크베셀 현황 분석해줘', + ]; + + return ( +
+ {/* Toggle header */} +
setIsOpen(p => !p)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + padding: '6px 8px', cursor: 'pointer', + background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)', + borderRadius: 4, + borderLeft: '2px solid rgba(168,85,247,0.5)', + }} + > + 🤖 + AI 해양분석 + Qwen 2.5 + + {isOpen ? '▼' : '▶'} + +
+ + {/* Chat body */} + {isOpen && ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ 해양 상황에 대해 질문하세요 +
+
+ {quickQuestions.map((q, i) => ( + + ))} +
+
+ )} + {messages.map((msg, i) => ( +
+ {msg.content} +
+ ))} + {isLoading && ( +
+ 분석 중... +
+ )} +
+
+ + {/* Input */} +
+ setInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }} + placeholder="해양 상황 질문..." + disabled={isLoading} + style={{ + flex: 1, background: 'rgba(139,92,246,0.06)', + border: '1px solid rgba(139,92,246,0.2)', + borderRadius: 4, padding: '5px 8px', + fontSize: 10, color: '#e2e8f0', outline: 'none', + }} + /> + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 02497da..7df115b 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -242,14 +242,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: illegalFishingData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name || d.mmsi, - getSize: 10 * zoomScale, + getSize: 14 * zoomScale, getColor: [239, 68, 68, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], fontFamily: 'monospace', - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', updateTriggers: { getSize: [zoomScale] }, @@ -279,14 +280,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data, getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat], getText: (d: { name: string }) => d.name, - getSize: 12 * zoomScale, + getSize: 14 * zoomScale, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 3, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', updateTriggers: { getSize: [zoomScale] }, @@ -354,13 +356,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: gears, getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => d.name || d.mmsi, - getSize: 9 * zoomScale, + getSize: 13 * zoomScale, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 10], fontFamily: 'monospace', - outlineWidth: 2, + fontSettings: { sdf: true }, + outlineWidth: 8, outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', @@ -388,14 +391,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: [parent], getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`, - getSize: 11 * zoomScale, + getSize: 14 * zoomScale, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 18], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 3, + fontSettings: { sdf: true }, + outlineWidth: 8, outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', @@ -452,14 +456,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const prefix = role === 'LEADER' ? '★ ' : ''; return `${prefix}${d.name || d.mmsi}`; }, - getSize: 9 * zoomScale, + getSize: 13 * zoomScale, getColor: color, getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, - outlineWidth: 2, + fontSettings: { sdf: true }, + outlineWidth: 8, outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', diff --git a/frontend/src/data/meEnergyHazardFacilities.ts b/frontend/src/data/meEnergyHazardFacilities.ts new file mode 100644 index 0000000..b0debba --- /dev/null +++ b/frontend/src/data/meEnergyHazardFacilities.ts @@ -0,0 +1,194 @@ +// Middle East Energy & Hazard Facilities (OSINT + OpenStreetMap) + +export type FacilitySubType = + | 'power' | 'wind' | 'nuclear' | 'thermal' // energy + | 'petrochem' | 'lng' | 'oil_tank' | 'haz_port'; // hazard + +export interface EnergyHazardFacility { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + country: string; // ISO-2 + countryKey: string; // overseas layer key prefix (us, il, ir, ae, sa, om, qa, kw, iq, bh) + category: 'energy' | 'hazard'; + subType: FacilitySubType; + capacityMW?: number; + description: string; +} + +export const SUB_TYPE_META: Record = { + power: { label: '발전소', color: '#a855f7', icon: '⚡' }, + wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' }, + nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' }, + thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' }, + petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' }, + lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' }, + oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' }, + haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' }, +}; + +// layer key -> subType mapping +export function layerKeyToSubType(key: string): FacilitySubType | null { + if (key.endsWith('Power')) return 'power'; + if (key.endsWith('Wind')) return 'wind'; + if (key.endsWith('Nuclear')) return 'nuclear'; + if (key.endsWith('Thermal')) return 'thermal'; + if (key.endsWith('Petrochem')) return 'petrochem'; + if (key.endsWith('Lng')) return 'lng'; + if (key.endsWith('OilTank')) return 'oil_tank'; + if (key.endsWith('HazPort')) return 'haz_port'; + return null; +} + +export function layerKeyToCountry(key: string): string | null { + const m = key.match(/^(us|il|ir|ae|sa|om|qa|kw|iq|bh)/); + return m ? m[1] : null; +} + +export const ME_ENERGY_HAZARD_FACILITIES: EnergyHazardFacility[] = [ + // ════════════════════════════════════════════ + // 🇺🇸 미국 (중동 주둔 시설 + 에너지 인프라) + // ════════════════════════════════════════════ + { id: 'US-E01', name: 'Al Udeid Power Plant', nameKo: '알우데이드 발전소', lat: 25.1175, lng: 51.3150, country: 'US', countryKey: 'us', category: 'energy', subType: 'power', capacityMW: 200, description: '미군 알우데이드 기지 전용 발전시설' }, + { id: 'US-H01', name: 'Bahrain NAVSUP Fuel Depot', nameKo: '바레인 미해군 유류저장소', lat: 26.2361, lng: 50.6036, country: 'US', countryKey: 'us', category: 'hazard', subType: 'oil_tank', description: 'NSA Bahrain 유류 보급 시설' }, + { id: 'US-H02', name: 'Jebel Ali US Navy Fuel Terminal', nameKo: '제벨알리 미해군 연료터미널', lat: 25.0100, lng: 55.0600, country: 'US', countryKey: 'us', category: 'hazard', subType: 'haz_port', description: '미 제5함대 연료 보급 항만' }, + + // ════════════════════════════════════════════ + // 🇮🇱 이스라엘 + // ════════════════════════════════════════════ + // Energy + { id: 'IL-E01', name: 'Orot Rabin Power Station', nameKo: '오롯 라빈 화력발전소', lat: 32.3915, lng: 34.8610, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2590, description: '이스라엘 최대 석탄/가스 복합 발전소 (하데라)' }, + { id: 'IL-E02', name: 'Rutenberg Power Station', nameKo: '루텐베르그 화력발전소', lat: 31.6200, lng: 34.5300, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2250, description: '아슈켈론 석탄 화력발전소' }, + { id: 'IL-E03', name: 'Eshkol Power Station', nameKo: '에쉬콜 발전소', lat: 31.7940, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 1096, description: '아슈도드 해안 천연가스 복합화력 (IEC 운영)' }, + { id: 'IL-E04', name: 'Hagit Power Station', nameKo: '하깃 발전소', lat: 32.5600, lng: 35.0800, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 600, description: '북부 가스터빈 발전소' }, + { id: 'IL-E05', name: 'Dimona Nuclear Research Center', nameKo: '디모나 원자력연구센터', lat: 31.0014, lng: 35.1467, country: 'IL', countryKey: 'il', category: 'energy', subType: 'nuclear', description: '네게브 원자력연구시설 (IRR-2)' }, + { id: 'IL-E06', name: 'Ashalim Solar Power Station', nameKo: '아샬림 태양광발전소', lat: 31.1300, lng: 34.6600, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 310, description: '네게브 사막 CSP+PV 복합 발전' }, + // Hazard + { id: 'IL-H01', name: 'Haifa Bay Petrochemical Complex', nameKo: '하이파만 석유화학단지', lat: 32.8100, lng: 35.0500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'petrochem', description: 'Oil Refineries Ltd. + Bazan Group 정유/석유화학 단지' }, + { id: 'IL-H02', name: 'Ashdod Oil Terminal', nameKo: '아시도드 유류터미널', lat: 31.8200, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: 'EAPC 원유 수입 터미널 + 저장탱크' }, + { id: 'IL-H03', name: 'Ashkelon Desalination & Energy Hub', nameKo: '아슈켈론 에너지허브', lat: 31.6100, lng: 34.5400, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'haz_port', description: '해수담수화 + LNG 수입 터미널' }, + { id: 'IL-H04', name: 'Eilat-Ashkelon Pipeline Terminal', nameKo: 'EAPC 에일라트 터미널', lat: 29.5500, lng: 34.9500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: '홍해 원유 수입 파이프라인 터미널' }, + + // ════════════════════════════════════════════ + // 🇮🇷 이란 (Wikipedia + OSINT 기반) + // 총 설치용량 ~85,000MW, 화력 95%+, 수력 ~12,000MW + // ════════════════════════════════════════════ + // ── 화력발전소 (Thermal) ── + { id: 'IR-E01', name: 'Damavand Power Plant', nameKo: '다마반드 발전소', lat: 35.5200, lng: 51.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2900, description: '이란 최대 화력발전소, 테헤란 남동 50km (가스복합)' }, + { id: 'IR-E02', name: 'Shahid Salimi (Neka) Power Plant', nameKo: '샤히드 살리미(네카) 발전소', lat: 36.6500, lng: 53.3300, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2214, description: '마잔다란주, 이란 2위 화력 (카스피해 연안)' }, + { id: 'IR-E03', name: 'Shahid Rajaee Combined Cycle', nameKo: '샤히드 라자이 복합화력', lat: 36.3700, lng: 49.9900, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2042, description: '가즈빈주, 이란 3위 복합화력' }, + { id: 'IR-E04', name: 'Ramin Steam Power Plant', nameKo: '라민 증기화력발전소', lat: 31.3100, lng: 48.7400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1890, description: '후제스탄주 아바즈 인근 증기터빈' }, + { id: 'IR-E05', name: 'Shahid Montazeri Power Plant', nameKo: '샤히드 몬타제리 발전소', lat: 32.6500, lng: 51.6800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1600, description: '이스파한, 1984년 가동 개시' }, + { id: 'IR-E06', name: 'Parand Combined Cycle', nameKo: '파란드 복합화력', lat: 35.4700, lng: 51.0100, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1536, description: '테헤란 서남부 복합화력' }, + { id: 'IR-E07', name: 'Tabriz Thermal Power Plant', nameKo: '타브리즈 화력발전소', lat: 38.0600, lng: 46.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1386, description: '동아제르바이잔주' }, + { id: 'IR-E08', name: 'Bandar Abbas Power Plant', nameKo: '반다르아바스 발전소', lat: 27.2000, lng: 56.2500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1057, description: '호르무즈해협 연안' }, + { id: 'IR-E09', name: 'Besat Power Plant', nameKo: '베사트 발전소', lat: 35.8300, lng: 50.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '테헤란 남부 가스터빈' }, + { id: 'IR-E10', name: 'Tous Power Plant', nameKo: '투스 발전소', lat: 36.3100, lng: 59.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1470, description: '마슈하드 인근 복합화력' }, + { id: 'IR-E11', name: 'Fars (Shahid Dastjerdi) Power Plant', nameKo: '파르스 발전소', lat: 29.6000, lng: 52.5000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1028, description: '시라즈 인근 가스복합' }, + { id: 'IR-E12', name: 'Hormozgan Power Plant', nameKo: '호르모즈간 발전소', lat: 27.1800, lng: 56.3000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 906, description: '호르모즈간주 가스복합' }, + { id: 'IR-E13', name: 'Shahid Mofateh Power Plant', nameKo: '샤히드 모파테 발전소', lat: 34.7700, lng: 48.5200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '하메단주 복합화력' }, + { id: 'IR-E14', name: 'Kerman Combined Cycle', nameKo: '케르만 복합화력', lat: 30.2600, lng: 57.0700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 928, description: '케르만주 복합화력' }, + { id: 'IR-E15', name: 'Yazd Combined Cycle', nameKo: '야즈드 복합화력', lat: 31.9000, lng: 54.3700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 948, description: '야즈드주 가스복합' }, + // ── 수력발전소 (Hydro) ── + { id: 'IR-E16', name: 'Karun-3 (Shahid Rajaee) Dam', nameKo: '카룬-3 수력발전소', lat: 31.8055, lng: 50.0893, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2280, description: '이란 최대 수력, 후제스탄주 이제 SE 28km, 8기' }, + { id: 'IR-E17', name: 'Shahid Abbaspour (Karun-1) Dam', nameKo: '카룬-1 (샤히드 아바스푸르) 수력', lat: 32.0519, lng: 49.6069, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '후제스탄주 마스제드솔레이만 NE 50km' }, + { id: 'IR-E18', name: 'Karun-4 Dam', nameKo: '카룬-4 수력발전소', lat: 31.5969, lng: 50.4712, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 1000, description: '카룬강 상류, 2011년 가동' }, + { id: 'IR-E19', name: 'Dez Dam Hydropower', nameKo: '데즈댐 수력발전소', lat: 32.6053, lng: 48.4640, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 520, description: '후제스탄주 안디메쉬크 NE 20km, 8기' }, + { id: 'IR-E20', name: 'Masjed Soleiman Dam', nameKo: '마스제드솔레이만 수력', lat: 32.0300, lng: 49.2800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '카룬강 하류, 대형 아치댐' }, + // ── 원자력/핵시설 (Nuclear) ── + { id: 'IR-E21', name: 'Bushehr Nuclear Power Plant', nameKo: '부셰르 원자력발전소', lat: 28.8267, lng: 50.8867, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 915, description: '이란 유일 상업 원전 (VVER-1000), 1995 러시아 계약' }, + { id: 'IR-E22', name: 'Natanz Enrichment Facility', nameKo: '나탄즈 우라늄농축시설', lat: 33.7250, lng: 51.7267, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '주요 원심분리기 농축시설 (지하)' }, + { id: 'IR-E23', name: 'Fordow Enrichment Facility', nameKo: '포르도 우라늄농축시설', lat: 34.8800, lng: 51.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '지하 농축시설 (FFEP, 쿰 인근 산속)' }, + { id: 'IR-E24', name: 'Isfahan Nuclear Technology Center', nameKo: '이스파한 핵기술센터 (UCF)', lat: 32.7200, lng: 51.7200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '우라늄전환시설 + 연구용 원자로' }, + { id: 'IR-E25', name: 'Arak Heavy Water Reactor (IR-40)', nameKo: '아라크 중수로', lat: 34.0400, lng: 49.2400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: 'IR-40 중수 연구용 원자로 (마르카지주)' }, + { id: 'IR-E26', name: 'Darkhovin Nuclear Power Plant', nameKo: '다르코빈 원자력발전소', lat: 31.3700, lng: 48.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 360, description: '이란 자체 건설 원전 (2007 착공, 후제스탄주)' }, + // ── 풍력 (Wind) ── + { id: 'IR-E27', name: 'Manjil-Rudbar Wind Farm', nameKo: '만질-루드바르 풍력단지', lat: 36.7400, lng: 49.4200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 101, description: '길란주, 이란 최대 풍력 (2003 가동)' }, + { id: 'IR-E28', name: 'Binaloud Wind Farm', nameKo: '비날루드 풍력단지', lat: 36.2200, lng: 58.7500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 28, description: '라자비호라산주 니샤푸르 인근, 43기 x 660kW' }, + // ── 태양광 (Solar) ── + { id: 'IR-E29', name: 'Zarand Solar Power Plant', nameKo: '자란드 태양광발전소', lat: 30.8100, lng: 56.5600, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 10, description: '케르만주 태양광 시범단지' }, + // Hazard + { id: 'IR-H01', name: 'South Pars Gas Complex (Assaluyeh)', nameKo: '사우스파르스 가스단지 (아살루예)', lat: 27.4800, lng: 52.6100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '세계 최대 가스전 육상 처리시설 (20+ 페이즈)' }, + { id: 'IR-H02', name: 'Kharg Island Oil Terminal', nameKo: '하르그섬 원유터미널', lat: 29.2300, lng: 50.3100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '이란 원유 수출의 90% 처리 (저장 2,800만 배럴)' }, + { id: 'IR-H03', name: 'Bandar Imam Khomeini Petrochemical', nameKo: '반다르 이맘호메이니 석유화학', lat: 30.4300, lng: 49.0800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: 'Mahshahr 특별경제구역 석유화학단지' }, + { id: 'IR-H04', name: 'Tombak LNG Terminal', nameKo: '톰박 LNG터미널', lat: 27.5200, lng: 52.5500, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'lng', description: 'Iran LNG 수출 터미널 (건설중)' }, + { id: 'IR-H05', name: 'Bandar Abbas Oil Refinery', nameKo: '반다르아바스 정유소', lat: 27.2100, lng: 56.2800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '일 320,000배럴 정유시설' }, + { id: 'IR-H06', name: 'Lavan Island Oil Terminal', nameKo: '라반섬 원유터미널', lat: 26.8100, lng: 53.3600, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '페르시아만 원유 저장/선적 시설' }, + + // ════════════════════════════════════════════ + // 🇦🇪 UAE + // ════════════════════════════════════════════ + // Energy + { id: 'AE-E01', name: 'Barakah Nuclear Power Plant', nameKo: '바라카 원자력발전소', lat: 23.9592, lng: 52.2567, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'nuclear', capacityMW: 5600, description: '아랍 최초 상업 원전 (APR-1400 x4)' }, + { id: 'AE-E02', name: 'Jebel Ali Power & Desalination', nameKo: '제벨알리 발전/담수', lat: 25.0200, lng: 55.1100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 8695, description: '세계 최대 복합 발전/담수 단지' }, + { id: 'AE-E03', name: 'Shams Solar Power Station', nameKo: '샴스 태양광발전소', lat: 23.5800, lng: 53.7100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'power', capacityMW: 100, description: '아부다비 CSP 태양열 발전' }, + { id: 'AE-E04', name: 'Hassyan Clean Coal Power Plant', nameKo: '하시안 청정석탄발전소', lat: 24.9600, lng: 55.0300, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 2400, description: '두바이 석탄→가스 전환 중' }, + // Hazard + { id: 'AE-H01', name: 'Ruwais Industrial Complex (ADNOC)', nameKo: '루와이스 산업단지 (ADNOC)', lat: 24.1100, lng: 52.7300, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'petrochem', description: 'ADNOC 정유/석유화학 통합단지 (세계 최대급)' }, + { id: 'AE-H02', name: 'Das Island LNG Terminal', nameKo: '다스섬 LNG터미널', lat: 25.1600, lng: 52.8700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'lng', description: 'ADGAS LNG 수출 터미널 (연 570만톤)' }, + { id: 'AE-H03', name: 'Fujairah Oil Terminal (FOSC)', nameKo: '푸자이라 유류터미널', lat: 25.1200, lng: 56.3400, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'oil_tank', description: '세계 3대 벙커링 허브 (저장 1,400만m3)' }, + { id: 'AE-H04', name: 'Jebel Ali Free Zone Port', nameKo: '제벨알리 자유무역항', lat: 25.0000, lng: 55.0700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'haz_port', description: '중동 최대 항만 (위험물 취급)' }, + + // ════════════════════════════════════════════ + // 🇸🇦 사우디아라비아 + // ════════════════════════════════════════════ + // Energy + { id: 'SA-E01', name: 'Shoaiba Power & Desalination', nameKo: '쇼아이바 발전/담수', lat: 20.7000, lng: 39.5100, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 5600, description: '홍해 연안 세계 최대급 복합 발전/담수' }, + { id: 'SA-E02', name: 'Rabigh Power Plant', nameKo: '라비그 발전소', lat: 22.8000, lng: 39.0200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2100, description: '홍해 연안 가스복합 발전소' }, + { id: 'SA-E03', name: 'Dumat Al Jandal Wind Farm', nameKo: '두마트알잔달 풍력단지', lat: 29.8100, lng: 39.8700, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'wind', capacityMW: 400, description: '중동 최대 풍력단지' }, + { id: 'SA-E04', name: 'Jubail IWPP', nameKo: '주바일 발전소', lat: 27.0200, lng: 49.6200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2745, description: '동부 산업도시 복합 발전' }, + // Hazard + { id: 'SA-H01', name: 'Ras Tanura Oil Terminal', nameKo: '라스타누라 원유터미널', lat: 26.6400, lng: 50.1600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 해상 원유 선적 시설 (일 600만 배럴)' }, + { id: 'SA-H02', name: 'Jubail Industrial City (SABIC)', nameKo: '주바일 산업단지 (SABIC)', lat: 27.0000, lng: 49.6500, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '세계 최대 석유화학 산업단지' }, + { id: 'SA-H03', name: 'Yanbu Industrial City', nameKo: '얀부 산업단지', lat: 23.9600, lng: 38.2400, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '홍해 연안 정유/석유화학 단지' }, + { id: 'SA-H04', name: 'Ras Al-Khair LNG Import', nameKo: '라스알카이르 LNG', lat: 27.4800, lng: 49.2600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'lng', description: 'LNG 수입/가스화 터미널' }, + { id: 'SA-H05', name: 'Abqaiq Oil Processing', nameKo: '아브카이크 원유처리시설', lat: 25.9400, lng: 49.6800, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 원유 안정화 시설 (2019 공격 대상)' }, + + // ════════════════════════════════════════════ + // 🇴🇲 오만 + // ════════════════════════════════════════════ + { id: 'OM-E01', name: 'Barka Power & Desalination', nameKo: '바르카 발전/담수', lat: 23.6800, lng: 57.8700, country: 'OM', countryKey: 'om', category: 'energy', subType: 'thermal', capacityMW: 2007, description: 'GDF Suez 운영 복합발전' }, + { id: 'OM-E02', name: 'Dhofar Wind Farm', nameKo: '도파르 풍력단지', lat: 17.0200, lng: 54.1000, country: 'OM', countryKey: 'om', category: 'energy', subType: 'wind', capacityMW: 50, description: 'GCC 최초 대형 풍력단지' }, + { id: 'OM-H01', name: 'Sohar Industrial Port', nameKo: '소하르 산업항', lat: 24.3600, lng: 56.7400, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'petrochem', description: '정유소+석유화학+알루미늄 제련단지' }, + { id: 'OM-H02', name: 'Qalhat LNG Terminal', nameKo: '칼하트 LNG터미널', lat: 22.9200, lng: 59.3700, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'lng', description: 'Oman LNG 수출 (연 1,060만톤)' }, + + // ════════════════════════════════════════════ + // 🇶🇦 카타르 + // ════════════════════════════════════════════ + { id: 'QA-E01', name: 'Ras Laffan Power Plant', nameKo: '라스라판 발전소', lat: 25.9100, lng: 51.5500, country: 'QA', countryKey: 'qa', category: 'energy', subType: 'thermal', capacityMW: 2730, description: '카타르 최대 발전소' }, + { id: 'QA-H01', name: 'Ras Laffan Industrial City', nameKo: '라스라판 산업단지', lat: 25.9200, lng: 51.5300, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'lng', description: '세계 최대 LNG 수출기지 (QatarEnergy, 연 7,700만톤)' }, + { id: 'QA-H02', name: 'Mesaieed Industrial City', nameKo: '메사이드 산업단지', lat: 24.9900, lng: 51.5600, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'petrochem', description: 'QatarEnergy 정유/석유화학/비료 단지' }, + { id: 'QA-H03', name: 'Dukhan Oil Field Terminal', nameKo: '두칸 유전터미널', lat: 25.4300, lng: 50.7700, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'oil_tank', description: '서부 해안 육상 유전 터미널' }, + + // ════════════════════════════════════════════ + // 🇰🇼 쿠웨이트 + // ════════════════════════════════════════════ + { id: 'KW-E01', name: 'Az-Zour Power Plant', nameKo: '아즈주르 발전소', lat: 28.7200, lng: 48.3800, country: 'KW', countryKey: 'kw', category: 'energy', subType: 'thermal', capacityMW: 4800, description: '쿠웨이트 최대 발전/담수' }, + { id: 'KW-H01', name: 'Mina Al Ahmadi Refinery', nameKo: '미나알아흐마디 정유소', lat: 29.0600, lng: 48.1500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'petrochem', description: 'KNPC 운영 (일 466,000배럴)' }, + { id: 'KW-H02', name: 'Az-Zour LNG Import Terminal', nameKo: '아즈주르 LNG터미널', lat: 28.7100, lng: 48.3500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'lng', description: '쿠웨이트 LNG 수입 터미널' }, + { id: 'KW-H03', name: 'Mina Abdullah Oil Tank Farm', nameKo: '미나압둘라 유류저장기지', lat: 29.0000, lng: 48.1700, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'oil_tank', description: '남부 원유 저장/선적' }, + + // ════════════════════════════════════════════ + // 🇮🇶 이라크 + // ════════════════════════════════════════════ + { id: 'IQ-E01', name: 'Basra Gas Power Plant', nameKo: '바스라 가스발전소', lat: 30.5100, lng: 47.7800, country: 'IQ', countryKey: 'iq', category: 'energy', subType: 'thermal', capacityMW: 1500, description: '남부 이라크 최대 발전소' }, + { id: 'IQ-H01', name: 'Basra Oil Terminal (ABOT)', nameKo: '알바스라 원유터미널', lat: 29.6800, lng: 48.8000, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 원유 수출의 85% (페르시아만)' }, + { id: 'IQ-H02', name: 'Khor Al-Zubair Port', nameKo: '코르알주바이르 항', lat: 30.1700, lng: 47.8700, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'haz_port', description: '이라크 주요 위험물 하역항' }, + { id: 'IQ-H03', name: 'Rumaila Oil Field', nameKo: '루마일라 유전', lat: 30.6300, lng: 47.4300, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 최대 유전 (일 150만 배럴)' }, + + // ════════════════════════════════════════════ + // 🇧🇭 바레인 + // ════════════════════════════════════════════ + { id: 'BH-E01', name: 'Al Dur Power & Water Plant', nameKo: '알두르 발전/담수', lat: 25.9400, lng: 50.6200, country: 'BH', countryKey: 'bh', category: 'energy', subType: 'thermal', capacityMW: 1234, description: '바레인 최대 발전소' }, + { id: 'BH-H01', name: 'Sitra Oil Refinery (BAPCO)', nameKo: '시트라 정유소 (BAPCO)', lat: 26.1500, lng: 50.6100, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'petrochem', description: '바레인 유일 정유시설 (일 267,000배럴)' }, + { id: 'BH-H02', name: 'Khalifa Bin Salman Port', nameKo: '칼리파빈살만항', lat: 26.0200, lng: 50.5500, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'haz_port', description: '바레인 주요 무역항 (위험물 하역)' }, +]; + +// Helper: filter by country key and subType +export function filterFacilities(countryKey: string, subType?: FacilitySubType): EnergyHazardFacility[] { + return ME_ENERGY_HAZARD_FACILITIES.filter(f => + f.countryKey === countryKey && (subType ? f.subType === subType : true) + ); +} diff --git a/frontend/src/data/sampleData.ts b/frontend/src/data/sampleData.ts index 44bc8df..47de567 100644 --- a/frontend/src/data/sampleData.ts +++ b/frontend/src/data/sampleData.ts @@ -1429,6 +1429,48 @@ export const sampleEvents: GeoEvent[] = [ label: 'UN 안보리 — 호르무즈 해협 긴급회의 소집', description: 'UN 안보리, 호르무즈 해협 상선 피격 관련 긴급회의 소집. 중국·러시아 즉각 휴전 촉구, 미국 항행자유 강조.', }, + + // ═══ D+20 (2026-03-21) 나탄즈-디모나 핵시설 교차공격 ═══ + { + id: 'd20-il1', timestamp: T0 + 20 * DAY + 4 * HOUR, + lat: 33.7250, lng: 51.7267, type: 'strike', + label: '나탄즈 — 이스라엘 핵시설 공습', + description: 'IAF, 이란 나탄즈 우라늄 농축시설 정밀 타격. 이란 원자력청 "나탄즈 농축시설이 공격 표적이 됐다" 확인. IAEA 방사능 유출 미확인.', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Natanz_nuclear.jpg/320px-Natanz_nuclear.jpg', + imageCaption: '나탄즈 핵시설 위성사진 (Wikimedia Commons)', + }, + { + id: 'd20-ir-assess', timestamp: T0 + 20 * DAY + 6 * HOUR, + lat: 33.7250, lng: 51.7267, type: 'alert', + label: '나탄즈 — 이란 방사능 조사 착수', + description: '이란 원자력안전센터, 나탄즈 시설 인근 방사성 오염물질 배출 가능성 정밀 기술 조사. "현재까지 방사성 물질 누출 보고 없음, 인근 주민 위협 없음" 발표.', + }, + { + id: 'd20-ir1', timestamp: T0 + 20 * DAY + 10 * HOUR, + lat: 31.0014, lng: 35.1467, type: 'strike', + label: '디모나 — 이란 보복 미사일 공격', + description: 'IRGC, 나탄즈 피격 보복으로 이스라엘 디모나 핵연구센터 겨냥 탄도미사일 발사. 이스라엘 방공 요격 실패, 최소 30명 이상 사상자 발생.', + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Negev_Nuclear_Research_Center.jpg/320px-Negev_Nuclear_Research_Center.jpg', + imageCaption: '디모나 네게브 핵연구센터 (Wikimedia Commons)', + }, + { + id: 'd20-il-def', timestamp: T0 + 20 * DAY + 10.5 * HOUR, + lat: 31.0014, lng: 35.1467, type: 'alert', + label: '디모나 — 요격 실패 조사 착수', + description: '이스라엘군, 이란발 탄도미사일 요격 실패 경위 조사 착수. 미사일이 마을에 충돌, 막대한 재산 피해.', + }, + { + id: 'd20-iaea', timestamp: T0 + 20 * DAY + 12 * HOUR, + lat: 48.2082, lng: 16.3738, type: 'alert', + label: 'IAEA — 양측 핵시설 상황 파악 중', + description: 'IAEA, 나탄즈 및 디모나 핵시설 상황 파악 중. 그로시 사무총장 "핵사고 위험 회피 위해 군사행동 자제 거듭 촉구".', + }, + { + id: 'd20-p1', timestamp: T0 + 20 * DAY + 14 * HOUR, + lat: 38.8977, lng: -77.0365, type: 'alert', + label: '워싱턴 — 미국 핵시설 공격 우려 성명', + description: '미 국무부, 이란의 디모나 공격에 강력 규탄. "핵시설 겨냥 군사행동은 국제법 중대 위반" 경고.', + }, ]; // 24시간 동안 10분 간격 센서 데이터 생성 diff --git a/frontend/src/data/zones/fishing-zones-wgs84.json b/frontend/src/data/zones/fishing-zones-wgs84.json index 639cbed..bbbeae8 100644 --- a/frontend/src/data/zones/fishing-zones-wgs84.json +++ b/frontend/src/data/zones/fishing-zones-wgs84.json @@ -1 +1,37 @@ -{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"id": "ZONE_I", "name": "수역Ⅰ(동해)"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[131.265, 36.1666], [130.7116, 35.705], [130.656608, 35.65], [129.716194, 35.65], [129.719062, 35.663246], [129.721805, 35.679265], [129.72751, 35.691992], [129.735849, 35.718435], [129.738702, 35.732259], [129.744407, 35.74367], [129.75121, 35.7632], [129.756805, 35.779329], [129.765363, 35.795567], [129.775019, 35.819486], [129.780614, 35.843624], [129.783577, 35.874565], [129.783577, 35.892998], [129.78632, 35.901007], [129.800254, 35.920537], [129.818467, 35.95839], [129.829768, 35.991635], [129.829768, 35.993062], [129.832292, 36.004911], [129.831743, 36.023564], [129.832292, 36.04441], [129.830646, 36.068], [129.826587, 36.090163], [129.818138, 36.11101], [129.808044, 36.139866], [129.794439, 36.168393], [129.781163, 36.186168], [129.767448, 36.202187], [129.745175, 36.223253], [129.720817, 36.241686], [129.702055, 36.253645], [129.67671, 36.266702], [129.673723, 36.268056], [129.660252, 36.274162], [129.643794, 36.279539], [129.629641, 36.283708], [129.629421, 36.290511], [129.648732, 36.315527], [129.659813, 36.336702], [129.665519, 36.347784], [129.675394, 36.37291], [129.682306, 36.396938], [129.685049, 36.424258], [129.686475, 36.440168], [129.690644, 36.453883], [129.699093, 36.483397], [129.701946, 36.514118], [129.70041, 36.540232], [129.691742, 36.570843], [129.687572, 36.586862], [129.691742, 36.59937], [129.707212, 36.630092], [129.71972, 36.66191], [129.724109, 36.689121], [129.725425, 36.711723], [129.726742, 36.743541], [129.728168, 36.786771], [129.72367, 36.819467], [129.716648, 36.838668], [129.706773, 36.857978], [129.695362, 36.881787], [129.681209, 36.910095], [129.67287, 36.925785], [129.671334, 36.936098], [129.671334, 36.954092], [129.667055, 36.97439], [129.668371, 36.987996], [129.668371, 37.001491], [129.671114, 37.009501], [129.67671, 37.029908], [129.679563, 37.0682], [129.675174, 37.101006], [129.666726, 37.128107], [129.658279, 37.145001], [129.658277, 37.145004], [129.649719, 37.158499], [129.628105, 37.184393], [129.618559, 37.193171], [129.621302, 37.208751], [129.622289, 37.233218], [129.622289, 37.256149], [129.617571, 37.279959], [129.611976, 37.296965], [129.603747, 37.313971], [129.595298, 37.33021], [129.575988, 37.351605], [129.561176, 37.367953], [129.54801, 37.379035], [129.538793, 37.38869], [129.524859, 37.403393], [129.499843, 37.427092], [129.49326, 37.44333], [129.476802, 37.474381], [129.461112, 37.495886], [129.438729, 37.516403], [129.424795, 37.528253], [129.408995, 37.551843], [129.395061, 37.571153], [129.379371, 37.583661], [129.36818, 37.593207], [129.367851, 37.595511], [129.363462, 37.626562], [129.349637, 37.653772], [129.338446, 37.674399], [129.325499, 37.69349], [129.307834, 37.710497], [129.303226, 37.726625], [129.296752, 37.743632], [129.282818, 37.767112], [129.267128, 37.788397], [129.249573, 37.806172], [129.235529, 37.819338], [129.219839, 37.828994], [129.214244, 37.831846], [129.203052, 37.851705], [129.19592, 37.858344], [129.176171, 37.876721], [129.159713, 37.889888], [129.139196, 37.901628], [129.116923, 37.931032], [129.095527, 37.950892], [129.079838, 37.962522], [129.069743, 37.971299], [129.061185, 37.991049], [129.048238, 38.008055], [129.035401, 38.025062], [129.021357, 38.041081], [128.999304, 38.053479], [128.993598, 38.067962], [128.977908, 38.087053], [128.970448, 38.096379], [128.956733, 38.121395], [128.93808, 38.143339], [128.920416, 38.160236], [128.913174, 38.166051], [128.899788, 38.17417], [128.893534, 38.182838], [128.881356, 38.198967], [128.865666, 38.216522], [128.857437, 38.250864], [128.85595, 38.2544], [129.413676, 38.2544], [129.99651, 38.2544], [130.164272, 38.002769], [131.6666, 38.002784], [131.6666, 37.425], [131.632657, 37.326115], [131.632455, 37.325711], [131.631139, 37.323188], [131.629712, 37.320445], [131.628396, 37.317812], [131.628286, 37.317373], [131.627737, 37.316276], [131.626311, 37.313533], [131.625324, 37.310899], [131.624007, 37.308156], [131.6228, 37.305523], [131.621922, 37.30278], [131.620825, 37.300037], [131.619838, 37.297294], [131.61874, 37.294551], [131.617972, 37.291589], [131.617204, 37.288956], [131.616217, 37.286103], [131.615559, 37.28336], [131.6149, 37.280507], [131.614242, 37.277764], [131.613803, 37.274911], [131.613254, 37.272059], [131.612706, 37.269316], [131.612157, 37.266463], [131.612145, 37.266296], [131.5666, 37.1333], [131.427341, 37.040556], [131.1666, 36.8666], [130.375, 36.8666], [130.375, 36.1666], [131.265, 36.1666]], [[130.54053, 37.533959], [130.54042, 37.532532], [130.54031, 37.530996], [130.54031, 37.529679], [130.540091, 37.528143], [130.539871, 37.5254], [130.539871, 37.523974], [130.539871, 37.522438], [130.539762, 37.521231], [130.539762, 37.519695], [130.539762, 37.518159], [130.539762, 37.516842], [130.539762, 37.515416], [130.539871, 37.51388], [130.539871, 37.512563], [130.539871, 37.511027], [130.540091, 37.508284], [130.54031, 37.506858], [130.54031, 37.505432], [130.54042, 37.504005], [130.54053, 37.502579], [130.540859, 37.501152], [130.540968, 37.499726], [130.541188, 37.496983], [130.541627, 37.49413], [130.542175, 37.491278], [130.542614, 37.488425], [130.543053, 37.485682], [130.543821, 37.482829], [130.544479, 37.479977], [130.545138, 37.477124], [130.545796, 37.474381], [130.546674, 37.471638], [130.547552, 37.468895], [130.548429, 37.466152], [130.549307, 37.463409], [130.550404, 37.460556], [130.550514, 37.460337], [130.550733, 37.458691], [130.551172, 37.455838], [130.551721, 37.452986], [130.552489, 37.450133], [130.552928, 37.44739], [130.553696, 37.444537], [130.554464, 37.441904], [130.555122, 37.438942], [130.556, 37.436199], [130.556768, 37.433456], [130.556987, 37.433017], [130.557865, 37.430713], [130.558633, 37.42797], [130.55973, 37.425227], [130.560828, 37.422484], [130.561925, 37.419741], [130.563132, 37.417108], [130.564229, 37.414365], [130.565546, 37.411731], [130.566862, 37.409098], [130.568289, 37.406355], [130.569605, 37.403722], [130.570922, 37.401089], [130.572568, 37.398565], [130.574104, 37.396041], [130.575749, 37.393408], [130.577505, 37.390885], [130.579041, 37.388251], [130.580797, 37.385838], [130.582442, 37.383314], [130.584308, 37.3809], [130.586283, 37.378377], [130.588148, 37.376073], [130.590123, 37.373549], [130.591878, 37.371245], [130.593963, 37.368831], [130.596267, 37.366527], [130.598242, 37.364223], [130.600436, 37.361809], [130.602631, 37.359615], [130.604715, 37.35742], [130.607239, 37.355116], [130.609324, 37.353032], [130.611847, 37.350837], [130.614151, 37.348643], [130.614919, 37.347984], [130.615139, 37.347765], [130.617443, 37.34568], [130.619637, 37.343376], [130.622051, 37.341182], [130.624684, 37.339207], [130.626988, 37.337013], [130.629402, 37.335038], [130.631816, 37.332953], [130.63434, 37.330868], [130.637083, 37.328893], [130.639606, 37.326918], [130.642349, 37.325053], [130.644982, 37.323188], [130.647725, 37.321213], [130.650468, 37.319348], [130.653211, 37.317592], [130.656064, 37.315837], [130.658807, 37.314081], [130.661879, 37.312435], [130.664622, 37.31079], [130.667475, 37.308924], [130.670657, 37.307279], [130.673509, 37.305852], [130.676472, 37.304316], [130.679544, 37.30278], [130.682506, 37.301244], [130.685798, 37.299927], [130.68887, 37.298501], [130.691942, 37.297075], [130.695234, 37.295758], [130.698525, 37.294332], [130.701707, 37.293125], [130.704779, 37.291918], [130.708071, 37.290821], [130.710485, 37.289943], [130.712569, 37.289175], [130.715641, 37.287749], [130.718933, 37.286432], [130.722115, 37.285115], [130.725406, 37.284018], [130.728479, 37.282811], [130.73188, 37.281604], [130.735171, 37.280507], [130.738463, 37.27941], [130.741864, 37.278422], [130.745266, 37.277325], [130.748667, 37.276448], [130.752068, 37.27546], [130.75536, 37.274692], [130.758761, 37.273705], [130.762162, 37.273046], [130.763369, 37.272498], [130.766661, 37.271071], [130.769733, 37.269974], [130.773134, 37.268877], [130.776316, 37.26767], [130.779717, 37.266573], [130.783009, 37.265476], [130.78641, 37.264378], [130.789702, 37.263391], [130.793103, 37.262513], [130.796505, 37.261635], [130.799906, 37.260648], [130.803307, 37.25977], [130.806818, 37.259002], [130.810219, 37.258124], [130.81373, 37.257466], [130.817022, 37.256808], [130.820752, 37.256149], [130.824263, 37.255711], [130.827665, 37.255162], [130.831176, 37.254613], [130.834687, 37.254065], [130.838308, 37.253626], [130.841819, 37.253187], [130.84533, 37.252748], [130.847195, 37.252638], [130.848841, 37.252529], [130.852461, 37.25209], [130.854217, 37.25209], [130.855972, 37.25198], [130.857947, 37.25187], [130.859703, 37.251651], [130.861458, 37.251651], [130.863324, 37.251541], [130.864969, 37.251541], [130.866835, 37.251432], [130.86848, 37.251432], [130.870346, 37.251322], [130.872211, 37.251322], [130.873857, 37.251322], [130.875722, 37.251322], [130.877477, 37.251322], [130.877697, 37.251322], [130.879233, 37.251322], [130.881208, 37.251322], [130.882963, 37.251432], [130.884719, 37.251432], [130.886474, 37.251432], [130.88834, 37.251541], [130.890095, 37.251541], [130.891851, 37.251651], [130.893716, 37.25187], [130.895362, 37.25198], [130.897227, 37.25198], [130.899092, 37.25209], [130.902713, 37.252529], [130.904249, 37.252638], [130.906224, 37.252748], [130.909735, 37.253187], [130.913356, 37.253626], [130.916867, 37.254065], [130.920378, 37.254613], [130.923779, 37.255162], [130.92729, 37.255711], [130.930911, 37.256149], [130.934422, 37.256808], [130.937823, 37.257466], [130.941334, 37.258124], [130.944735, 37.259002], [130.948137, 37.25977], [130.951648, 37.260648], [130.954939, 37.261635], [130.95834, 37.262513], [130.961742, 37.263391], [130.965143, 37.264378], [130.968325, 37.265476], [130.971726, 37.266573], [130.975127, 37.26767], [130.978419, 37.268877], [130.981601, 37.269974], [130.985002, 37.271071], [130.988074, 37.272498], [130.991256, 37.273705], [130.994438, 37.275131], [130.99762, 37.276448], [131.000692, 37.277874], [131.003984, 37.27919], [131.006946, 37.280727], [131.010018, 37.282372], [131.012981, 37.283908], [131.015833, 37.285444], [131.018905, 37.28709], [131.021758, 37.288736], [131.024611, 37.290382], [131.027573, 37.292028], [131.030426, 37.293783], [131.033279, 37.295648], [131.036022, 37.297404], [131.038655, 37.299159], [131.041508, 37.301134], [131.044141, 37.303], [131.046774, 37.304975], [131.049407, 37.306949], [131.052041, 37.308924], [131.054674, 37.311009], [131.057088, 37.312984], [131.059502, 37.315069], [131.061806, 37.317263], [131.064329, 37.319238], [131.065865, 37.320664], [131.067182, 37.321542], [131.070144, 37.323188], [131.072997, 37.324943], [131.07585, 37.326589], [131.078593, 37.328345], [131.081445, 37.33021], [131.084079, 37.332075], [131.086822, 37.33394], [131.089565, 37.335806], [131.092198, 37.337781], [131.094831, 37.339756], [131.097355, 37.34173], [131.099988, 37.343815], [131.102402, 37.3459], [131.104925, 37.347765], [131.107339, 37.349959], [131.109643, 37.352154], [131.112167, 37.354238], [131.114361, 37.356433], [131.116556, 37.358627], [131.118969, 37.360931], [131.121164, 37.363235], [131.123248, 37.36554], [131.125333, 37.367734], [131.127418, 37.370148], [131.129393, 37.372452], [131.131477, 37.374756], [131.133343, 37.377279], [131.135427, 37.379584], [131.137292, 37.382107], [131.138938, 37.384521], [131.140803, 37.387044], [131.142559, 37.389568], [131.144205, 37.392092], [131.14596, 37.394615], [131.147387, 37.397248], [131.148923, 37.399772], [131.150349, 37.402295], [131.151995, 37.405038], [131.153311, 37.407562], [131.154738, 37.410305], [131.156054, 37.412938], [131.157152, 37.415681], [131.158359, 37.418314], [131.159565, 37.421057], [131.160772, 37.4238], [131.16176, 37.426434], [131.162857, 37.429177], [131.163735, 37.43192], [131.164832, 37.434663], [131.16571, 37.437515], [131.166478, 37.440258], [131.167136, 37.443111], [131.167246, 37.443989], [131.168892, 37.446293], [131.170208, 37.448926], [131.171744, 37.451559], [131.173061, 37.454083], [131.174487, 37.456716], [131.175804, 37.459459], [131.177011, 37.461983], [131.178108, 37.464726], [131.179315, 37.467469], [131.180412, 37.470212], [131.181509, 37.472955], [131.182606, 37.475588], [131.183375, 37.47855], [131.184472, 37.481184], [131.18524, 37.484036], [131.185898, 37.48667], [131.186776, 37.489522], [131.187434, 37.492265], [131.188092, 37.495008], [131.188751, 37.497971], [131.189299, 37.500714], [131.189848, 37.503566], [131.190287, 37.506309], [131.190726, 37.509272], [131.190945, 37.512015], [131.191274, 37.513551], [131.191384, 37.514867], [131.191494, 37.516294], [131.191494, 37.51783], [131.191603, 37.519146], [131.191933, 37.520683], [131.191933, 37.523425], [131.192042, 37.524852], [131.192042, 37.526278], [131.192042, 37.527595], [131.192042, 37.529131], [131.192042, 37.530557], [131.192042, 37.531984], [131.192042, 37.53341], [131.192042, 37.534836], [131.192042, 37.536263], [131.191933, 37.537799], [131.191933, 37.540542], [131.191603, 37.541858], [131.191494, 37.543394], [131.191494, 37.544821], [131.191384, 37.546137], [131.191274, 37.547673], [131.190945, 37.5491], [131.190726, 37.551843], [131.190287, 37.554695], [131.189848, 37.557438], [131.189299, 37.560401], [131.188751, 37.563144], [131.188092, 37.565997], [131.187434, 37.56874], [131.186776, 37.571702], [131.185898, 37.574335], [131.18524, 37.577188], [131.184472, 37.579821], [131.183375, 37.582674], [131.182606, 37.585307], [131.181509, 37.58816], [131.180412, 37.590793], [131.179315, 37.593536], [131.178108, 37.596169], [131.177011, 37.598912], [131.175804, 37.601546], [131.174487, 37.604289], [131.173061, 37.606922], [131.171744, 37.609555], [131.170208, 37.612188], [131.168892, 37.614822], [131.167246, 37.617455], [131.16571, 37.619869], [131.164174, 37.622502], [131.162418, 37.624916], [131.160772, 37.627439], [131.158907, 37.629853], [131.157152, 37.632486], [131.155396, 37.63479], [131.153531, 37.637314], [131.151446, 37.639618], [131.149581, 37.641922], [131.147496, 37.644446], [131.145521, 37.64664], [131.143327, 37.648944], [131.141352, 37.651248], [131.139158, 37.653552], [131.136854, 37.655747], [131.134549, 37.657941], [131.132355, 37.660136], [131.129941, 37.66233], [131.127637, 37.664415], [131.125223, 37.666499], [131.1227, 37.668584], [131.120176, 37.670778], [131.117653, 37.672753], [131.115129, 37.674728], [131.11491, 37.675057], [131.113593, 37.676155], [131.112496, 37.677142], [131.110192, 37.679337], [131.107668, 37.681421], [131.105364, 37.683506], [131.102731, 37.685481], [131.100207, 37.687456], [131.097684, 37.68954], [131.09516, 37.691406], [131.092527, 37.693381], [131.089784, 37.695356], [131.08726, 37.697221], [131.084408, 37.699086], [131.081665, 37.700841], [131.078702, 37.702597], [131.075959, 37.704243], [131.073216, 37.706108], [131.070364, 37.707863], [131.067511, 37.7094], [131.064329, 37.711045], [131.061367, 37.712691], [131.058404, 37.714117], [131.055442, 37.715544], [131.05237, 37.71708], [131.049188, 37.718506], [131.046116, 37.720042], [131.043153, 37.721359], [131.039862, 37.722785], [131.03668, 37.723992], [131.033388, 37.725199], [131.030316, 37.726516], [131.027025, 37.727723], [131.023733, 37.72882], [131.020441, 37.729917], [131.01704, 37.731014], [131.013858, 37.732111], [131.013419, 37.732221], [131.012871, 37.732441], [131.009579, 37.733538], [131.006397, 37.734745], [131.002996, 37.735842], [130.999924, 37.736939], [130.996523, 37.738036], [130.993121, 37.738914], [130.98972, 37.740011], [130.986319, 37.740999], [130.983027, 37.741767], [130.979626, 37.742754], [130.976225, 37.743522], [130.972604, 37.74429], [130.969203, 37.744949], [130.967118, 37.745387], [130.965692, 37.745607], [130.96229, 37.746375], [130.958779, 37.747033], [130.955268, 37.747582], [130.951867, 37.74813], [130.948246, 37.748679], [130.944735, 37.749008], [130.941224, 37.749557], [130.937713, 37.749776], [130.935738, 37.750105], [130.933983, 37.750215], [130.930472, 37.750435], [130.926961, 37.750764], [130.925095, 37.750873], [130.92345, 37.750983], [130.921584, 37.750983], [130.919719, 37.751203], [130.918073, 37.751312], [130.916208, 37.751312], [130.914453, 37.751422], [130.912697, 37.751422], [130.910942, 37.751422], [130.908967, 37.751422], [130.907211, 37.751422], [130.905456, 37.751532], [130.90359, 37.751532], [130.901945, 37.751422], [130.900079, 37.751422], [130.898214, 37.751422], [130.896568, 37.751422], [130.894703, 37.751312], [130.893057, 37.751312], [130.891192, 37.751203], [130.889437, 37.751203], [130.887681, 37.750983], [130.885706, 37.750873], [130.883951, 37.750764], [130.88044, 37.750435], [130.876929, 37.750215], [130.875063, 37.750105], [130.873418, 37.749776], [130.869797, 37.749557], [130.866286, 37.749008], [130.862775, 37.748679], [130.859264, 37.74813], [130.855643, 37.747582], [130.852132, 37.747033], [130.848731, 37.746375], [130.84522, 37.745607], [130.841819, 37.744949], [130.838308, 37.74429], [130.834906, 37.743522], [130.831505, 37.742754], [130.827884, 37.741767], [130.824483, 37.740999], [130.821082, 37.740011], [130.818119, 37.739682], [130.816254, 37.739463], [130.814389, 37.739353], [130.810987, 37.738914], [130.807476, 37.738585], [130.803965, 37.738146], [130.800454, 37.737597], [130.796834, 37.737049], [130.793213, 37.7365], [130.789921, 37.735842], [130.78641, 37.735184], [130.783009, 37.734525], [130.779498, 37.733648], [130.776097, 37.732989], [130.772476, 37.732111], [130.769075, 37.731343], [130.765673, 37.730356], [130.762272, 37.729368], [130.758871, 37.728491], [130.755579, 37.727613], [130.752397, 37.726516], [130.748996, 37.725419], [130.745814, 37.724102], [130.742413, 37.723114], [130.739231, 37.721908], [130.73594, 37.720701], [130.732867, 37.719274], [130.729576, 37.718177], [130.726504, 37.71686], [130.725077, 37.716312], [130.721786, 37.715434], [130.718384, 37.714666], [130.717287, 37.714337], [130.714983, 37.713788], [130.711582, 37.712911], [130.708181, 37.712033], [130.704779, 37.711045], [130.701488, 37.709948], [130.698086, 37.708851], [130.695014, 37.707863], [130.691613, 37.706766], [130.688321, 37.705559], [130.68514, 37.704243], [130.681848, 37.703036], [130.678776, 37.701939], [130.675484, 37.700512], [130.672193, 37.699196], [130.66923, 37.697769], [130.666048, 37.696453], [130.662867, 37.695026], [130.659904, 37.69349], [130.656832, 37.691954], [130.65387, 37.690528], [130.651017, 37.688882], [130.647945, 37.687346], [130.645092, 37.6857], [130.642239, 37.683835], [130.639277, 37.682189], [130.636424, 37.680543], [130.633681, 37.678678], [130.631048, 37.676813], [130.628195, 37.675057], [130.625452, 37.673083], [130.622929, 37.671327], [130.620186, 37.669352], [130.617662, 37.667377], [130.615139, 37.665402], [130.612615, 37.663317], [130.610092, 37.661233], [130.607787, 37.659368], [130.605154, 37.657173], [130.60274, 37.655089], [130.600436, 37.652894], [130.598242, 37.6507], [130.595938, 37.648396], [130.593634, 37.646201], [130.591659, 37.644007], [130.589464, 37.641703], [130.587489, 37.639508], [130.585185, 37.636985], [130.583101, 37.634681], [130.581345, 37.632267], [130.57937, 37.629853], [130.577505, 37.627439], [130.57553, 37.625025], [130.573884, 37.622612], [130.572019, 37.620198], [130.570263, 37.617674], [130.568618, 37.615041], [130.567191, 37.612517], [130.565546, 37.609994], [130.564119, 37.607361], [130.562583, 37.604837], [130.561047, 37.602094], [130.55973, 37.59968], [130.558414, 37.596937], [130.557207, 37.594414], [130.555781, 37.591671], [130.554574, 37.589038], [130.553367, 37.586295], [130.552489, 37.583661], [130.551392, 37.580809], [130.550404, 37.578175], [130.549307, 37.575432], [130.548539, 37.57258], [130.547661, 37.569837], [130.546893, 37.567094], [130.545906, 37.564241], [130.545248, 37.561498], [130.544589, 37.558755], [130.54415, 37.555902], [130.543492, 37.55305], [130.542943, 37.550307], [130.542943, 37.549868], [130.542614, 37.548112], [130.542175, 37.54526], [130.541627, 37.542407], [130.541188, 37.539554], [130.540859, 37.535275], [130.54053, 37.533959]]], [[[128.813313, 34.343917], [128.813549, 34.343982], [128.816182, 34.35243], [128.806308, 34.41486], [128.804991, 34.4266], [128.789301, 34.509548], [128.830226, 34.561884], [128.835822, 34.572417], [128.842734, 34.581853], [128.849866, 34.592276], [128.932814, 34.708908], [128.966388, 34.755429], [128.979115, 34.774081], [128.996012, 34.797342], [129.015762, 34.824113], [129.039571, 34.860211], [129.105731, 34.950729], [129.150716, 35.011184], [129.191532, 35.0599], [129.2352, 35.105214], [129.268555, 35.136923], [129.447873, 35.131455], [129.462867, 35.130998], [129.464184, 35.13813], [129.467805, 35.147456], [129.475485, 35.159086], [129.48909, 35.188052], [129.503244, 35.194525], [129.520141, 35.20451], [129.537147, 35.217237], [129.552069, 35.23183], [129.564797, 35.245654], [129.576317, 35.261235], [129.586192, 35.277692], [129.593872, 35.292066], [129.599029, 35.307207], [129.604515, 35.323994], [129.607368, 35.340342], [129.615267, 35.346048], [129.630738, 35.358226], [129.647854, 35.373368], [129.659155, 35.390813], [129.669688, 35.408807], [129.67693, 35.430202], [129.681428, 35.439748], [129.68834, 35.46619], [129.691193, 35.476723], [129.698435, 35.489341], [129.710833, 35.518197], [129.719391, 35.549248], [129.720708, 35.574593], [129.717745, 35.601035], [129.71204, 35.622979], [129.710723, 35.629891], [129.713576, 35.637901], [129.716194, 35.65], [130.656608, 35.65], [130.5683, 35.5616], [130.3883, 35.3033], [130.2733, 35.1166], [130.125, 35.1133], [129.712736, 35.071743], [129.6783, 35.0683], [129.5483, 35.02], [129.3766, 34.96], [129.3066, 34.905], [129.2633, 34.8733], [129.2166, 34.8433], [129.0516, 34.6716], [129.0133, 34.5433], [129.0133, 34.535], [129.0033, 34.4866], [128.99, 34.46], [128.8883, 34.3083], [128.887965, 34.307977], [128.813313, 34.343917]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_II", "name": "수역Ⅱ(남해)"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[126.000509, 32.1833], [126.000154, 33.128268], [126.000787, 33.127635], [126.00364, 33.124892], [126.006492, 33.122368], [126.007151, 33.12171], [126.007919, 33.119515], [126.008467, 33.11765], [126.009126, 33.116004], [126.009784, 33.114249], [126.010333, 33.112493], [126.01121, 33.110848], [126.011869, 33.109092], [126.012527, 33.107446], [126.013185, 33.105691], [126.014063, 33.104045], [126.014831, 33.102399], [126.015599, 33.100753], [126.016477, 33.098998], [126.017245, 33.097242], [126.018123, 33.095706], [126.019, 33.093951], [126.019878, 33.092415], [126.020756, 33.090659], [126.021634, 33.089123], [126.022511, 33.087477], [126.023609, 33.085941], [126.024596, 33.084186], [126.025474, 33.08265], [126.026461, 33.081004], [126.027559, 33.079468], [126.028546, 33.077822], [126.029533, 33.076176], [126.030631, 33.07464], [126.03107, 33.073104], [126.031508, 33.071568], [126.032057, 33.069813], [126.032386, 33.068057], [126.032935, 33.066302], [126.033483, 33.064546], [126.034032, 33.06279], [126.034581, 33.060925], [126.035239, 33.059279], [126.035787, 33.057524], [126.036446, 33.055878], [126.037104, 33.054013], [126.037653, 33.052367], [126.038311, 33.050612], [126.039079, 33.048856], [126.039847, 33.04721], [126.040505, 33.045455], [126.041383, 33.043809], [126.042041, 33.042054], [126.04281, 33.040408], [126.043687, 33.038762], [126.044455, 33.037006], [126.045223, 33.035361], [126.046211, 33.033715], [126.046979, 33.032069], [126.047966, 33.030423], [126.048954, 33.028887], [126.049722, 33.027132], [126.050819, 33.025596], [126.051697, 33.02384], [126.052684, 33.022304], [126.053672, 33.020658], [126.054769, 33.019013], [126.055647, 33.017476], [126.056744, 33.015831], [126.057841, 33.014404], [126.058938, 33.012759], [126.060035, 33.011222], [126.061133, 33.009686], [126.06234, 33.00815], [126.063546, 33.006614], [126.064644, 33.005078], [126.065851, 33.003542], [126.067057, 33.002116], [126.069471, 32.999153], [126.071885, 32.996191], [126.074628, 32.993229], [126.077152, 32.990376], [126.079895, 32.987523], [126.082528, 32.98478], [126.085381, 32.982037], [126.088233, 32.979294], [126.091196, 32.976551], [126.094158, 32.974028], [126.09535, 32.973041], [126.09734, 32.971394], [126.100302, 32.968871], [126.103594, 32.966567], [126.106776, 32.964043], [126.107105, 32.963824], [126.107434, 32.963495], [126.110616, 32.961081], [126.113798, 32.958667], [126.117199, 32.956363], [126.1206, 32.954059], [126.124002, 32.951974], [126.127293, 32.94978], [126.130804, 32.947585], [126.134535, 32.945501], [126.138046, 32.943526], [126.139801, 32.942538], [126.141667, 32.941551], [126.143422, 32.940673], [126.145287, 32.939686], [126.147043, 32.938808], [126.149018, 32.93782], [126.150883, 32.937052], [126.152748, 32.936065], [126.154613, 32.935187], [126.156588, 32.934419], [126.158454, 32.933432], [126.160319, 32.932663], [126.162184, 32.931895], [126.164269, 32.931018], [126.166134, 32.93025], [126.167999, 32.929482], [126.170084, 32.928823], [126.171949, 32.927946], [126.174034, 32.927287], [126.176009, 32.926629], [126.177874, 32.925971], [126.179959, 32.925203], [126.181933, 32.924544], [126.184018, 32.923886], [126.185993, 32.923337], [126.188078, 32.922679], [126.190053, 32.922021], [126.192137, 32.921472], [126.194112, 32.920924], [126.196197, 32.920375], [126.198172, 32.919826], [126.200366, 32.919168], [126.202341, 32.918729], [126.204426, 32.918181], [126.206401, 32.917742], [126.208595, 32.917303], [126.21068, 32.916974], [126.212765, 32.916425], [126.214849, 32.915986], [126.217044, 32.915547], [126.219019, 32.915218], [126.221213, 32.914889], [126.223188, 32.91456], [126.225382, 32.914231], [126.227577, 32.913902], [126.229552, 32.913572], [126.231746, 32.913243], [126.23394, 32.913024], [126.236025, 32.912695], [126.238219, 32.912475], [126.240194, 32.912365], [126.242389, 32.912146], [126.244583, 32.911927], [126.246778, 32.911817], [126.248752, 32.911597], [126.251057, 32.911488], [126.253141, 32.911378], [126.255226, 32.911268], [126.25742, 32.911159], [126.259615, 32.911049], [126.261699, 32.911049], [126.263894, 32.911049], [126.266088, 32.911049], [126.268283, 32.910939], [126.270367, 32.910939], [126.272452, 32.911049], [126.274646, 32.911049], [126.276731, 32.911049], [126.278925, 32.911159], [126.28112, 32.911268], [126.283204, 32.911378], [126.285399, 32.911488], [126.287593, 32.911597], [126.289787, 32.911817], [126.291762, 32.911927], [126.293957, 32.912146], [126.296041, 32.912256], [126.298236, 32.912475], [126.30043, 32.912695], [126.302405, 32.913024], [126.3046, 32.913243], [126.306794, 32.913572], [126.308988, 32.913902], [126.310963, 32.914231], [126.313158, 32.91456], [126.315242, 32.914889], [126.317327, 32.915218], [126.319521, 32.915547], [126.321496, 32.915986], [126.323691, 32.916425], [126.325666, 32.916974], [126.32786, 32.917303], [126.329945, 32.917742], [126.33192, 32.918181], [126.334114, 32.918729], [126.336089, 32.919168], [126.338174, 32.919826], [126.340149, 32.920375], [126.342233, 32.920924], [126.344318, 32.921472], [126.346403, 32.922021], [126.348378, 32.922679], [126.350462, 32.923337], [126.352437, 32.923886], [126.354412, 32.924544], [126.356387, 32.925203], [126.358472, 32.925971], [126.360447, 32.926629], [126.362312, 32.927287], [126.364397, 32.927946], [126.366371, 32.928823], [126.368346, 32.929482], [126.370212, 32.93025], [126.372187, 32.931018], [126.374162, 32.931895], [126.376027, 32.932663], [126.377892, 32.933432], [126.379977, 32.934419], [126.381732, 32.935187], [126.383597, 32.936065], [126.385463, 32.937052], [126.387328, 32.93782], [126.389303, 32.938808], [126.391058, 32.939686], [126.392924, 32.940673], [126.394789, 32.941551], [126.396544, 32.942538], [126.39841, 32.943526], [126.401921, 32.945501], [126.405541, 32.947585], [126.409052, 32.94978], [126.412344, 32.951974], [126.415855, 32.954059], [126.419146, 32.956363], [126.422548, 32.958667], [126.42573, 32.961081], [126.429021, 32.963495], [126.432093, 32.965908], [126.435275, 32.968432], [126.438347, 32.971065], [126.4412, 32.973698], [126.444162, 32.976332], [126.447125, 32.979075], [126.449868, 32.981818], [126.452721, 32.984561], [126.455244, 32.987413], [126.457987, 32.990266], [126.460511, 32.993229], [126.463144, 32.996191], [126.465558, 32.999153], [126.467862, 33.002116], [126.469069, 33.003652], [126.470166, 33.005188], [126.471373, 33.006724], [126.47247, 33.00826], [126.473457, 33.009796], [126.474555, 33.011332], [126.475652, 33.012868], [126.476749, 33.014514], [126.478614, 33.017476], [126.480699, 33.017257], [126.482784, 33.016928], [126.484978, 33.016708], [126.487063, 33.016489], [126.489147, 33.01616], [126.491342, 33.01594], [126.493536, 33.015831], [126.495511, 33.015611], [126.497815, 33.015502], [126.4999, 33.015282], [126.501984, 33.015172], [126.504179, 33.015063], [126.506373, 33.015063], [126.508348, 33.014953], [126.510652, 33.014953], [126.512737, 33.014843], [126.514931, 33.014843], [126.517016, 33.014843], [126.51921, 33.014843], [126.521405, 33.014843], [126.523489, 33.014953], [126.525684, 33.014953], [126.527878, 33.015063], [126.530073, 33.015063], [126.532048, 33.015172], [126.534242, 33.015282], [126.536436, 33.015502], [126.538521, 33.015611], [126.540715, 33.015831], [126.54291, 33.01594], [126.544885, 33.01616], [126.547079, 33.016489], [126.549273, 33.016708], [126.551248, 33.016928], [126.553443, 33.017257], [126.555637, 33.017476], [126.557722, 33.017806], [126.559806, 33.018025], [126.561891, 33.018354], [126.563976, 33.018683], [126.56617, 33.019013], [126.568145, 33.019561], [126.57034, 33.01989], [126.572314, 33.020329], [126.574509, 33.020768], [126.576594, 33.021207], [126.578678, 33.021536], [126.580763, 33.022085], [126.582738, 33.022633], [126.584932, 33.023182], [126.586907, 33.023621], [126.588992, 33.024169], [126.590967, 33.024718], [126.593051, 33.025376], [126.595026, 33.025925], [126.595904, 33.026144], [126.596124, 33.026035], [126.598318, 33.025596], [126.600293, 33.025047], [126.602378, 33.024608], [126.604572, 33.024279], [126.606547, 33.02384], [126.608741, 33.023511], [126.610936, 33.023182], [126.612911, 33.022853], [126.615105, 33.022524], [126.61719, 33.022085], [126.619274, 33.021865], [126.621469, 33.021536], [126.623553, 33.021317], [126.625638, 33.020987], [126.627832, 33.020768], [126.629917, 33.020549], [126.632111, 33.020439], [126.634306, 33.020219], [126.636281, 33.02011], [126.638475, 33.01989], [126.64067, 33.019781], [126.642754, 33.019671], [126.644949, 33.019561], [126.647143, 33.019451], [126.649118, 33.019342], [126.651312, 33.019342], [126.653507, 33.019342], [126.655701, 33.019122], [126.657786, 33.019122], [126.65998, 33.019122], [126.662175, 33.019342], [126.664259, 33.019342], [126.666454, 33.019342], [126.668648, 33.019451], [126.670623, 33.019561], [126.672817, 33.019671], [126.675012, 33.019781], [126.677096, 33.01989], [126.679291, 33.02011], [126.681485, 33.020219], [126.68346, 33.020439], [126.685654, 33.020549], [126.687849, 33.020768], [126.689824, 33.020987], [126.692018, 33.021317], [126.694213, 33.021536], [126.696297, 33.021865], [126.698492, 33.022085], [126.700576, 33.022524], [126.702661, 33.022853], [126.704746, 33.023182], [126.70694, 33.023511], [126.708915, 33.02384], [126.711109, 33.024279], [126.713084, 33.024608], [126.715279, 33.025047], [126.717254, 33.025596], [126.719448, 33.026035], [126.721533, 33.026473], [126.723508, 33.027022], [126.725702, 33.027461], [126.727677, 33.02801], [126.729762, 33.028668], [126.731737, 33.029107], [126.733821, 33.029655], [126.735796, 33.030204], [126.737881, 33.030862], [126.739856, 33.031521], [126.74194, 33.032179], [126.743915, 33.032727], [126.746, 33.033386], [126.747865, 33.034044], [126.74995, 33.034812], [126.751925, 33.03547], [126.75379, 33.036129], [126.755875, 33.036897], [126.75774, 33.037775], [126.759715, 33.038433], [126.76169, 33.039201], [126.763555, 33.039969], [126.76542, 33.040847], [126.767505, 33.041615], [126.76937, 33.042492], [126.771235, 33.04337], [126.773101, 33.044248], [126.775076, 33.045126], [126.776831, 33.046003], [126.778696, 33.046991], [126.780562, 33.047869], [126.782317, 33.048746], [126.784292, 33.049734], [126.786048, 33.050612], [126.787913, 33.051599], [126.789668, 33.052696], [126.793179, 33.054671], [126.79669, 33.056756], [126.800201, 33.05895], [126.803603, 33.061035], [126.807004, 33.063339], [126.810295, 33.065643], [126.813587, 33.067947], [126.816879, 33.070361], [126.82006, 33.072775], [126.823023, 33.075189], [126.826205, 33.077712], [126.829167, 33.080346], [126.832239, 33.082979], [126.835092, 33.085722], [126.837945, 33.088245], [126.840797, 33.090988], [126.843431, 33.093841], [126.845076, 33.095597], [126.848039, 33.096694], [126.850124, 33.097462], [126.851989, 33.09834], [126.853854, 33.099108], [126.855939, 33.099876], [126.857804, 33.100863], [126.859669, 33.101631], [126.861534, 33.102509], [126.863509, 33.103387], [126.865375, 33.104374], [126.86713, 33.105252], [126.868995, 33.10613], [126.870751, 33.107117], [126.872726, 33.108105], [126.874481, 33.108982], [126.87514, 33.109311], [126.876895, 33.109531], [126.87898, 33.10997], [126.881064, 33.110299], [126.883259, 33.110628], [126.885234, 33.110957], [126.887428, 33.111396], [126.889403, 33.111725], [126.891597, 33.112164], [126.893792, 33.112603], [126.895767, 33.113152], [126.897851, 33.11359], [126.899826, 33.114029], [126.902021, 33.114468], [126.903996, 33.114907], [126.90608, 33.115456], [126.908055, 33.116114], [126.91025, 33.116663], [126.912334, 33.117211], [126.914309, 33.11776], [126.916394, 33.118308], [126.918369, 33.118857], [126.920454, 33.119625], [126.922429, 33.120283], [126.924294, 33.120942], [126.926378, 33.12149], [126.928353, 33.122368], [126.930328, 33.123026], [126.932303, 33.123685], [126.934388, 33.124343], [126.936253, 33.125221], [126.938118, 33.125989], [126.940203, 33.126757], [126.942068, 33.127525], [126.943933, 33.128403], [126.946018, 33.129171], [126.947883, 33.130048], [126.949749, 33.130816], [126.951614, 33.131804], [126.953589, 33.132572], [126.955344, 33.13345], [126.95721, 33.134437], [126.959075, 33.135315], [126.96083, 33.136302], [126.962695, 33.13718], [126.964451, 33.138168], [126.966426, 33.139155], [126.968181, 33.140143], [126.971692, 33.142227], [126.975203, 33.144312], [126.978714, 33.146287], [126.982116, 33.148591], [126.985407, 33.150785], [126.988809, 33.153089], [126.9921, 33.155394], [126.995282, 33.157807], [126.998574, 33.160221], [127.001646, 33.162745], [127.004828, 33.165268], [127.004937, 33.165488], [127.005815, 33.166036], [127.008997, 33.16834], [127.012179, 33.170864], [127.01547, 33.173278], [127.018433, 33.175911], [127.021505, 33.178435], [127.024358, 33.181068], [127.02732, 33.183701], [127.030173, 33.186444], [127.033025, 33.189187], [127.035768, 33.19204], [127.038402, 33.194783], [127.041035, 33.197745], [127.043449, 33.200598], [127.046082, 33.20356], [127.048496, 33.206523], [127.049703, 33.208059], [127.0508, 33.209595], [127.052007, 33.211021], [127.053104, 33.212667], [127.054311, 33.214203], [127.055408, 33.215739], [127.056396, 33.217275], [127.057493, 33.218921], [127.05848, 33.220457], [127.059578, 33.221993], [127.060675, 33.223529], [127.061552, 33.225285], [127.06254, 33.226821], [127.063637, 33.228467], [127.064515, 33.230003], [127.065502, 33.231648], [127.06649, 33.233294], [127.067258, 33.23494], [127.068245, 33.236586], [127.069013, 33.238232], [127.069781, 33.239877], [127.070769, 33.241633], [127.071537, 33.243279], [127.072415, 33.244924], [127.073073, 33.24657], [127.07417, 33.247448], [127.077242, 33.249862], [127.080424, 33.252276], [127.083387, 33.254909], [127.086459, 33.257542], [127.089421, 33.260066], [127.092274, 33.262699], [127.095127, 33.265442], [127.09787, 33.268185], [127.100503, 33.271038], [127.103246, 33.27389], [127.105769, 33.276743], [127.108403, 33.279705], [127.110816, 33.282558], [127.113121, 33.285521], [127.114327, 33.287166], [127.115534, 33.288593], [127.116632, 33.290238], [127.117838, 33.291665], [127.118936, 33.293311], [127.120033, 33.294847], [127.12113, 33.296383], [127.122117, 33.297919], [127.123105, 33.299565], [127.124202, 33.301101], [127.12508, 33.302746], [127.126177, 33.304283], [127.127165, 33.305928], [127.128152, 33.307464], [127.12903, 33.30911], [127.131334, 33.311524], [127.133967, 33.314486], [127.136381, 33.317449], [127.138685, 33.320411], [127.139892, 33.322057], [127.141099, 33.323483], [127.142196, 33.325129], [127.143293, 33.326556], [127.1445, 33.328092], [127.145597, 33.329737], [127.146585, 33.331164], [127.147572, 33.33281], [127.14867, 33.334346], [127.149767, 33.335991], [127.150644, 33.337527], [127.151632, 33.339173], [127.152729, 33.340709], [127.153717, 33.342355], [127.154594, 33.344001], [127.155582, 33.345647], [127.15635, 33.347183], [127.157337, 33.348938], [127.158105, 33.350474], [127.159093, 33.35223], [127.159861, 33.353876], [127.160739, 33.355521], [127.161507, 33.357167], [127.162275, 33.358923], [127.163043, 33.360568], [127.163811, 33.362214], [127.164469, 33.36386], [127.165127, 33.365616], [127.165786, 33.367261], [127.166554, 33.369017], [127.167212, 33.370772], [127.16787, 33.372528], [127.168529, 33.374174], [127.169077, 33.375929], [127.169626, 33.377685], [127.170284, 33.37944], [127.170723, 33.381086], [127.171272, 33.382951], [127.17182, 33.384707], [127.172369, 33.386462], [127.173027, 33.389315], [127.173795, 33.390193], [127.174892, 33.391729], [127.17599, 33.393265], [127.177197, 33.394801], [127.178294, 33.396337], [127.179391, 33.397873], [127.180488, 33.399519], [127.181366, 33.401055], [127.182463, 33.402701], [127.183451, 33.404237], [127.184548, 33.405773], [127.185425, 33.407419], [127.186413, 33.409064], [127.187291, 33.4106], [127.188278, 33.412356], [127.189266, 33.413892], [127.190034, 33.415538], [127.190802, 33.417184], [127.191789, 33.418829], [127.192557, 33.420475], [127.193435, 33.422231], [127.194203, 33.423876], [127.195081, 33.425632], [127.195739, 33.427168], [127.196507, 33.428924], [127.197165, 33.430569], [127.198043, 33.432325], [127.198702, 33.433971], [127.19936, 33.435726], [127.200018, 33.437372], [127.200676, 33.439237], [127.201225, 33.440883], [127.201774, 33.442638], [127.202322, 33.444284], [127.202981, 33.446149], [127.203529, 33.447795], [127.204078, 33.449551], [127.204517, 33.451306], [127.205065, 33.453171], [127.205504, 33.454817], [127.205943, 33.456573], [127.206382, 33.458328], [127.206821, 33.460194], [127.20704, 33.461291], [127.207163, 33.461721], [127.207698, 33.463595], [127.208247, 33.465241], [127.208796, 33.467106], [127.209235, 33.468861], [127.209673, 33.470617], [127.210003, 33.472372], [127.210441, 33.474128], [127.21088, 33.475883], [127.211319, 33.477749], [127.211648, 33.479394], [127.211978, 33.48126], [127.212307, 33.483015], [127.212636, 33.484771], [127.212855, 33.486526], [127.213184, 33.488391], [127.213294, 33.490147], [127.213514, 33.491902], [127.213843, 33.493658], [127.213952, 33.495523], [127.214062, 33.497388], [127.214282, 33.499144], [127.214391, 33.501009], [127.214501, 33.502655], [127.214501, 33.50452], [127.214611, 33.506276], [127.214611, 33.508141], [127.21483, 33.509896], [127.21483, 33.511762], [127.21483, 33.513517], [127.21483, 33.515382], [127.214611, 33.517138], [127.214501, 33.518893], [127.214501, 33.520759], [127.214391, 33.522514], [127.214282, 33.52427], [127.214282, 33.526135], [127.213952, 33.52789], [127.213843, 33.529756], [127.213514, 33.531511], [127.213514, 33.532169], [127.213404, 33.533376], [127.210332, 33.551041], [127.204736, 33.568706], [127.197933, 33.585273], [127.189924, 33.601951], [127.178733, 33.617531], [127.156898, 33.641998], [127.132541, 33.662845], [127.129469, 33.664381], [127.1016, 33.682046], [127.082948, 33.690823], [127.063637, 33.698065], [127.043778, 33.70388], [127.021944, 33.708049], [127.000878, 33.711121], [126.9921, 33.71167], [126.977727, 33.721435], [126.948542, 33.739648], [126.918808, 33.752705], [126.904983, 33.757313], [126.922429, 33.759837], [126.939874, 33.763019], [126.957319, 33.766091], [126.964122, 33.767627], [126.978385, 33.77026], [126.995831, 33.771906], [127.012618, 33.773442], [127.029405, 33.775417], [127.04685, 33.777063], [127.064295, 33.779038], [127.081083, 33.780135], [127.098528, 33.782219], [127.115315, 33.784304], [127.132541, 33.78584], [127.149986, 33.787486], [127.156898, 33.787925], [127.174344, 33.79001], [127.191679, 33.792094], [127.208467, 33.79363], [127.22657, 33.795276], [127.242589, 33.797251], [127.260035, 33.798787], [127.265411, 33.799665], [127.273201, 33.800433], [127.321477, 33.80537], [127.369972, 33.809199], [127.413203, 33.812612], [127.622766, 33.829508], [127.653927, 33.833787], [127.709444, 33.849587], [127.749273, 33.867032], [127.785699, 33.891719], [127.835731, 33.948554], [127.897942, 34.013069], [127.996251, 34.117631], [127.999323, 34.118509], [128.0, 34.118697], [128.75, 34.326406], [128.813549, 34.343982], [128.813313, 34.343917], [128.887965, 34.307977], [128.8883, 34.3083], [128.7933, 34.2166], [128.75, 34.183619], [128.6883, 34.1366], [128.435, 33.84], [128.425, 33.79], [128.3616, 33.7516], [128.0, 33.396458], [127.922434, 33.320087], [127.8716, 33.27], [127.86, 33.2283], [127.805, 33.145], [127.6983, 32.9583], [127.685, 32.95], [127.15, 32.5666], [126.535888, 32.1833], [126.000509, 32.1833]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_III", "name": "수역Ⅲ(서남해)"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[124.125526, 35.000703], [125.18669, 35.000703], [125.185796, 35.0], [125.080023, 34.916826], [125.03065, 34.87612], [125.006621, 34.855273], [124.994223, 34.84112], [124.977326, 34.819066], [124.963172, 34.793502], [124.95615, 34.772435], [124.94079, 34.734253], [124.935194, 34.714284], [124.932232, 34.691024], [124.915664, 34.596775], [124.90173, 34.515144], [124.887686, 34.427588], [124.859268, 34.25862], [124.834143, 34.112474], [124.846651, 34.040389], [124.858171, 34.003962], [124.878689, 33.970498], [124.904582, 33.937801], [124.944959, 33.90719], [124.983361, 33.881954], [125.012985, 33.865606], [125.061152, 33.852111], [125.395356, 33.801969], [125.578823, 33.774791], [125.867039, 33.732078], [125.875597, 33.730761], [125.884265, 33.729444], [125.892823, 33.728238], [125.901381, 33.726921], [125.90994, 33.725714], [125.918607, 33.724397], [125.927165, 33.723081], [125.935724, 33.721764], [125.944282, 33.720557], [125.952949, 33.71935], [125.960959, 33.718143], [125.969078, 33.716937], [125.977636, 33.7154], [125.986304, 33.713864], [125.993436, 33.712767], [126.001994, 33.711012], [126.010662, 33.709256], [126.014283, 33.708598], [126.022841, 33.706842], [126.031508, 33.705087], [126.040067, 33.703441], [126.048625, 33.701686], [126.061242, 33.699162], [126.060803, 33.697187], [126.060584, 33.695212], [126.060255, 33.692469], [126.060035, 33.690604], [126.059816, 33.688848], [126.059706, 33.687093], [126.059487, 33.685228], [126.059487, 33.683472], [126.059158, 33.681717], [126.059158, 33.679851], [126.059048, 33.678096], [126.059048, 33.676231], [126.059048, 33.674475], [126.059048, 33.67272], [126.058938, 33.670854], [126.059048, 33.669099], [126.059048, 33.667343], [126.059048, 33.665478], [126.059158, 33.663723], [126.059158, 33.661857], [126.059377, 33.660102], [126.059487, 33.658237], [126.059706, 33.656591], [126.059816, 33.654726], [126.060035, 33.65297], [126.060255, 33.651215], [126.060365, 33.649349], [126.060694, 33.647704], [126.060913, 33.645838], [126.061242, 33.644083], [126.061462, 33.642218], [126.061901, 33.640572], [126.06212, 33.638707], [126.062559, 33.637061], [126.062998, 33.635196], [126.063327, 33.63344], [126.063766, 33.631685], [126.064205, 33.629929], [126.064753, 33.628064], [126.065083, 33.626418], [126.065631, 33.624663], [126.06618, 33.622907], [126.066728, 33.621152], [126.067277, 33.619506], [126.067825, 33.617641], [126.068374, 33.615995], [126.069032, 33.614239], [126.069691, 33.612484], [126.070239, 33.610728], [126.070898, 33.609083], [126.071666, 33.607437], [126.072324, 33.605681], [126.072982, 33.604035], [126.07375, 33.60228], [126.074628, 33.600634], [126.075286, 33.598879], [126.076054, 33.597343], [126.076932, 33.595587], [126.0777, 33.593941], [126.078688, 33.592295], [126.079456, 33.59065], [126.080333, 33.589004], [126.081211, 33.587358], [126.082199, 33.585822], [126.083076, 33.584176], [126.083625, 33.583189], [126.082418, 33.58253], [126.079017, 33.580336], [126.075725, 33.578032], [126.072324, 33.575838], [126.068923, 33.573533], [126.065631, 33.57112], [126.062449, 33.568816], [126.059377, 33.566402], [126.056195, 33.563768], [126.053123, 33.561355], [126.05027, 33.558721], [126.047308, 33.556088], [126.044346, 33.553565], [126.041493, 33.550822], [126.03875, 33.547969], [126.036117, 33.545226], [126.033374, 33.542373], [126.03074, 33.539521], [126.028327, 33.536668], [126.025913, 33.533705], [126.023499, 33.530743], [126.022292, 33.529207], [126.021195, 33.527671], [126.019988, 33.526135], [126.018891, 33.524599], [126.017684, 33.523063], [126.016587, 33.521527], [126.015489, 33.51999], [126.014392, 33.518454], [126.013514, 33.516809], [126.012417, 33.515273], [126.01143, 33.513627], [126.010333, 33.512091], [126.009455, 33.510445], [126.008467, 33.508909], [126.00748, 33.507153], [126.006712, 33.505617], [126.005724, 33.503971], [126.004847, 33.502326], [126.003969, 33.50079], [126.003091, 33.499034], [126.002213, 33.497388], [126.001445, 33.495743], [126.000677, 33.494097], [125.999909, 33.492341], [125.999032, 33.490586], [125.998154, 33.489927], [125.994972, 33.487623], [125.9919, 33.48521], [125.988608, 33.482686], [125.985536, 33.480272], [125.982354, 33.477749], [125.979392, 33.475115], [125.976539, 33.472372], [125.973577, 33.469849], [125.970834, 33.467106], [125.968091, 33.464363], [125.965348, 33.46151], [125.962824, 33.458657], [125.960081, 33.455695], [125.957667, 33.452842], [125.955034, 33.44988], [125.95273, 33.446917], [125.951523, 33.445272], [125.950426, 33.443845], [125.949219, 33.442419], [125.948122, 33.440773], [125.947134, 33.439237], [125.946037, 33.437701], [125.94494, 33.436165], [125.943843, 33.434519], [125.942746, 33.432983], [125.941868, 33.431337], [125.940771, 33.429801], [125.939783, 33.428156], [125.938796, 33.426619], [125.937918, 33.424864], [125.93693, 33.423328], [125.936053, 33.421792], [125.935065, 33.420036], [125.934297, 33.4185], [125.93331, 33.416745], [125.932432, 33.415209], [125.931664, 33.413453], [125.930896, 33.411807], [125.930018, 33.410052], [125.92925, 33.408406], [125.928592, 33.406651], [125.927714, 33.405005], [125.927056, 33.403249], [125.926397, 33.401603], [125.925739, 33.399848], [125.925081, 33.398202], [125.924422, 33.396447], [125.923654, 33.394801], [125.923106, 33.393045], [125.922448, 33.3914], [125.921899, 33.389534], [125.92135, 33.387779], [125.920911, 33.386023], [125.920363, 33.384268], [125.919814, 33.382293], [125.918607, 33.37955], [125.917839, 33.377794], [125.917181, 33.376149], [125.916523, 33.374393], [125.915864, 33.372747], [125.915206, 33.370992], [125.914548, 33.369346], [125.913999, 33.36759], [125.913451, 33.365835], [125.912792, 33.364079], [125.912244, 33.362434], [125.911695, 33.360568], [125.911146, 33.358923], [125.910708, 33.357057], [125.910159, 33.355412], [125.90961, 33.353546], [125.909281, 33.351901], [125.908842, 33.350035], [125.908403, 33.34828], [125.908184, 33.346524], [125.907745, 33.344659], [125.907306, 33.343013], [125.907087, 33.341148], [125.906758, 33.339393], [125.906429, 33.337527], [125.906099, 33.335882], [125.90599, 33.334016], [125.90566, 33.332261], [125.905441, 33.330396], [125.905331, 33.32864], [125.905002, 33.326775], [125.904892, 33.32491], [125.904783, 33.323264], [125.904673, 33.321399], [125.904673, 33.319643], [125.904454, 33.317778], [125.904454, 33.316022], [125.904344, 33.314157], [125.904344, 33.312402], [125.904344, 33.310646], [125.904344, 33.308781], [125.904454, 33.307025], [125.904454, 33.30516], [125.904454, 33.305051], [125.904673, 33.303405], [125.904673, 33.30154], [125.904783, 33.299784], [125.904892, 33.297919], [125.905002, 33.296163], [125.905331, 33.294298], [125.905441, 33.292652], [125.90566, 33.290787], [125.90599, 33.288922], [125.906099, 33.287166], [125.906429, 33.285301], [125.906648, 33.283546], [125.907087, 33.28179], [125.907306, 33.280035], [125.907745, 33.278279], [125.907965, 33.276524], [125.908403, 33.274658], [125.908842, 33.272903], [125.909391, 33.271147], [125.90972, 33.269282], [125.910159, 33.267636], [125.910708, 33.265771], [125.911146, 33.264125], [125.911695, 33.26226], [125.912244, 33.260614], [125.912792, 33.258749], [125.913451, 33.257103], [125.913999, 33.255348], [125.914548, 33.253592], [125.915206, 33.251946], [125.915864, 33.250081], [125.916523, 33.248435], [125.917181, 33.24668], [125.917839, 33.245034], [125.918607, 33.243279], [125.919375, 33.241633], [125.920034, 33.239877], [125.920911, 33.238341], [125.921679, 33.236586], [125.922448, 33.23494], [125.923325, 33.233184], [125.924203, 33.231648], [125.925081, 33.229893], [125.925849, 33.228357], [125.926836, 33.226601], [125.927714, 33.224956], [125.928702, 33.223419], [125.929689, 33.221664], [125.930567, 33.220128], [125.931554, 33.218482], [125.932651, 33.216946], [125.933529, 33.2153], [125.934626, 33.213764], [125.935724, 33.212118], [125.936821, 33.210582], [125.937918, 33.209046], [125.939015, 33.20751], [125.940112, 33.205864], [125.941319, 33.204438], [125.942307, 33.202792], [125.943623, 33.201366], [125.94483, 33.19994], [125.947244, 33.196867], [125.949658, 33.194015], [125.952181, 33.191052], [125.954815, 33.1882], [125.957448, 33.185347], [125.960191, 33.182494], [125.96063, 33.182165], [125.961837, 33.179532], [125.962824, 33.177886], [125.963702, 33.17624], [125.96458, 33.174485], [125.965457, 33.172949], [125.966445, 33.171303], [125.967323, 33.169657], [125.96831, 33.168011], [125.969298, 33.166475], [125.970395, 33.164829], [125.971273, 33.163293], [125.97237, 33.161648], [125.973467, 33.160111], [125.974564, 33.158466], [125.975661, 33.157039], [125.976649, 33.155394], [125.977746, 33.153967], [125.978953, 33.152321], [125.98005, 33.150785], [125.982464, 33.147823], [125.984768, 33.14486], [125.987401, 33.141898], [125.989925, 33.138936], [125.992558, 33.136083], [125.995191, 33.13323], [125.997934, 33.130487], [126.000154, 33.128268], [126.000509, 32.1833], [125.4166, 32.1833], [125.291527, 32.296033], [125.291527, 32.296033], [124.170389, 33.300272], [124.1333, 33.3333], [124.0083, 34.0], [124.125, 35.0], [124.125, 35.0], [124.125526, 35.000703]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_IV", "name": "수역Ⅳ(서해)"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[124.5, 35.5], [124.5, 36.149546], [124.5, 36.75], [124.3333, 37.0], [124.727778, 37.0], [124.727778, 37.00285], [125.228908, 37.002852], [125.486775, 37.002853], [125.483132, 36.996554], [125.428382, 36.902634], [125.408742, 36.867524], [125.388993, 36.834718], [125.388133, 36.833333], [125.388133, 36.833333], [125.380544, 36.821113], [125.349823, 36.764388], [125.31186, 36.697459], [125.301985, 36.671346], [125.293537, 36.640625], [125.293537, 36.629214], [125.29222, 36.616816], [125.295073, 36.587191], [125.299352, 36.573586], [125.309227, 36.545169], [125.324807, 36.515654], [125.35871, 36.471328], [125.448022, 36.366656], [125.56959, 36.226544], [125.587914, 36.206027], [125.6, 36.192052], [125.636358, 36.15], [125.73779, 36.032561], [125.758966, 35.997121], [125.791443, 35.926023], [125.809876, 35.883672], [125.822713, 35.845819], [125.832697, 35.814878], [125.838183, 35.788435], [125.848387, 35.720629], [125.848833, 35.716667], [125.851349, 35.694296], [125.852666, 35.683983], [125.848387, 35.66555], [125.81218, 35.565486], [125.785408, 35.496143], [125.775643, 35.465093], [125.768511, 35.450061], [125.746603, 35.433333], [125.686771, 35.387631], [125.501126, 35.244228], [125.392723, 35.160732], [125.37429, 35.148004], [125.261667, 35.059608], [125.229351, 35.034225], [125.185796, 35.0], [125.18669, 35.000703], [124.125526, 35.000703], [124.5, 35.5]]]]}}]} \ No newline at end of file +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "zone": "II", "name": "특정어업수역 II (남해 제주남방)" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[126.00, 32.18], [128.89, 32.18], [128.89, 34.34], [126.00, 34.34], [126.00, 32.18]]] + } + }, + { + "type": "Feature", + "properties": { "zone": "III", "name": "특정어업수역 III (서남해 이어도)" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[124.01, 32.18], [126.08, 32.18], [126.08, 35.00], [124.01, 35.00], [124.01, 32.18]]] + } + }, + { + "type": "Feature", + "properties": { "zone": "IV", "name": "특정어업수역 IV (서해 중간수역)" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[124.13, 35.00], [125.85, 35.00], [125.85, 37.00], [124.13, 37.00], [124.13, 35.00]]] + } + }, + { + "type": "Feature", + "properties": { "zone": "I", "name": "특정어업수역 I (동해)" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[128.86, 35.65], [131.67, 35.65], [131.67, 38.25], [128.86, 38.25], [128.86, 35.65]]] + } + } + ] +} diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts index 0337f9e..92dfba6 100644 --- a/frontend/src/hooks/layers/createFacilityLayers.ts +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -126,7 +126,7 @@ export function createFacilityLayers( data: plants, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 8 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -134,8 +134,9 @@ export function createFacilityLayers( getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -186,7 +187,7 @@ export function createFacilityLayers( data: hazardData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', @@ -194,8 +195,9 @@ export function createFacilityLayers( getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -244,7 +246,7 @@ export function createFacilityLayers( data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', @@ -252,8 +254,9 @@ export function createFacilityLayers( getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -301,7 +304,7 @@ export function createFacilityLayers( data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', @@ -309,8 +312,9 @@ export function createFacilityLayers( getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts index b1de305..cc0160c 100644 --- a/frontend/src/hooks/layers/createMilitaryLayers.ts +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -35,10 +35,253 @@ function missileImpactSvg(color: string): string { `; } +// ─── MilitaryBase SVG ───────────────────────────────────────────────────────── + +function navalBaseSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function airforceBaseSvg(color: string, size: number): string { + return ` + + + `; +} + +function armyBaseSvg(color: string, size: number): string { + return ` + + + + + `; +} + +function missileBaseSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function jointBaseSvg(color: string, size: number): string { + return ` + + + `; +} + +// ─── GovBuilding SVG ────────────────────────────────────────────────────────── + +function governmentBuildingSvg(color: string, size: number): string { + return ` + + + + + + + + + `; +} + +function militaryHqSvg(color: string, size: number): string { + return ` + + + `; +} + +function intelligenceSvg(color: string, size: number): string { + return ` + + + + + `; +} + +function foreignSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function maritimeSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function defenseSvg(color: string, size: number): string { + return ` + + + + + `; +} + +// ─── NK Launch Site SVG ─────────────────────────────────────────────────────── + +function icbmSiteSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function srbmSiteSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function slbmSiteSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function cruiseSiteSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function artillerySiteSvg(color: string, size: number): string { + return ` + + + + + + `; +} + // ─── Module-level icon caches ───────────────────────────────────────────────── const launchIconCache = new Map(); const impactIconCache = new Map(); +const milBaseIconCache = new Map(); +const govBuildingIconCache = new Map(); +const nkLaunchIconCache = new Map(); + +// ─── MilitaryBase icon helpers ──────────────────────────────────────────────── + +const MIL_BASE_TYPE_COLOR: Record = { + naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', + missile: '#ef4444', joint: '#a78bfa', +}; + +type MilBaseSvgFn = (color: string, size: number) => string; + +const MIL_BASE_SVG_FN: Record = { + naval: navalBaseSvg, + airforce: airforceBaseSvg, + army: armyBaseSvg, + missile: missileBaseSvg, + joint: jointBaseSvg, +}; + +function getMilBaseIconUrl(type: string): string { + if (!milBaseIconCache.has(type)) { + const color = MIL_BASE_TYPE_COLOR[type] ?? '#a78bfa'; + const fn = MIL_BASE_SVG_FN[type] ?? jointBaseSvg; + milBaseIconCache.set(type, svgToDataUri(fn(color, 64))); + } + return milBaseIconCache.get(type)!; +} + +// ─── GovBuilding icon helpers ───────────────────────────────────────────────── + +const GOV_TYPE_COLOR: Record = { + executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', + intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', +}; + +type GovSvgFn = (color: string, size: number) => string; + +const GOV_SVG_FN: Record = { + executive: governmentBuildingSvg, + legislature: governmentBuildingSvg, + military_hq: militaryHqSvg, + intelligence: intelligenceSvg, + foreign: foreignSvg, + maritime: maritimeSvg, + defense: defenseSvg, +}; + +function getGovBuildingIconUrl(type: string): string { + if (!govBuildingIconCache.has(type)) { + const color = GOV_TYPE_COLOR[type] ?? '#f59e0b'; + const fn = GOV_SVG_FN[type] ?? governmentBuildingSvg; + govBuildingIconCache.set(type, svgToDataUri(fn(color, 64))); + } + return govBuildingIconCache.get(type)!; +} + +// ─── NKLaunchSite icon helpers ──────────────────────────────────────────────── + +type NkLaunchSvgFn = (color: string, size: number) => string; + +const NK_LAUNCH_SVG_FN: Record = { + icbm: icbmSiteSvg, + irbm: icbmSiteSvg, + srbm: srbmSiteSvg, + slbm: slbmSiteSvg, + cruise: cruiseSiteSvg, + artillery: artillerySiteSvg, + mlrs: artillerySiteSvg, +}; + +function getNkLaunchIconUrl(type: string): string { + if (!nkLaunchIconCache.has(type)) { + const color = NK_LAUNCH_TYPE_META[type]?.color ?? '#f97316'; + const fn = NK_LAUNCH_SVG_FN[type] ?? srbmSiteSvg; + nkLaunchIconCache.set(type, svgToDataUri(fn(color, 64))); + } + return nkLaunchIconCache.get(type)!; +} export function createMilitaryLayers( config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean }, @@ -48,28 +291,16 @@ export function createMilitaryLayers( const sc = fc.sc; const onPick = fc.onPick; - // ── Military Bases — TextLayer (이모지) ─────────────────────────────── + // ── Military Bases — IconLayer ──────────────────────────────────────── if (config.militaryBases) { - const TYPE_COLOR: Record = { - naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', - missile: '#ef4444', joint: '#a78bfa', - }; - const TYPE_ICON: Record = { - naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', - }; layers.push( - new TextLayer({ - id: 'static-militarybase-emoji', + new IconLayer({ + id: 'static-militarybase-icon', data: MILITARY_BASES, getPosition: (d) => [d.lng, d.lat], - getText: (d) => TYPE_ICON[d.type] ?? '⭐', - getSize: 14 * sc, + getIcon: (d) => ({ url: getMilBaseIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 16 * sc, updateTriggers: { getSize: [sc] }, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'militaryBase', object: info.object }); @@ -81,45 +312,33 @@ export function createMilitaryLayers( 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, + getSize: 11 * sc, updateTriggers: { getSize: [sc] }, - getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], + getColor: (d) => [...hexToRgb(MIL_BASE_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], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } - // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── + // ── Gov Buildings — IconLayer ───────────────────────────────────────── if (config.govBuildings) { - const GOV_TYPE_COLOR: Record = { - executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', - intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', - }; - const GOV_TYPE_ICON: Record = { - executive: '🏛', legislature: '🏛', military_hq: '⭐', - intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', - }; layers.push( - new TextLayer({ - id: 'static-govbuilding-emoji', + new IconLayer({ + id: 'static-govbuilding-icon', data: GOV_BUILDINGS, getPosition: (d) => [d.lng, d.lat], - getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', + getIcon: (d) => ({ url: getGovBuildingIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 12 * sc, updateTriggers: { getSize: [sc] }, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'govBuilding', object: info.object }); @@ -131,7 +350,7 @@ export function createMilitaryLayers( 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, + getSize: 11 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -139,29 +358,25 @@ export function createMilitaryLayers( getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } - // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── + // ── NK Launch Sites — IconLayer ─────────────────────────────────────── if (config.nkLaunch) { layers.push( - new TextLayer({ - id: 'static-nklaunch-emoji', + new IconLayer({ + id: 'static-nklaunch-icon', 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, + getIcon: (d) => ({ url: getNkLaunchIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 12 : 15) * sc, updateTriggers: { getSize: [sc] }, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'nkLaunch', object: info.object }); @@ -173,7 +388,7 @@ export function createMilitaryLayers( 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, + getSize: 11 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -181,8 +396,9 @@ export function createMilitaryLayers( getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -264,7 +480,7 @@ export function createMilitaryLayers( data: impactData, getPosition: (d) => [d.lng, d.lat], getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, - getSize: 8 * sc, + getSize: 11 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -272,8 +488,9 @@ export function createMilitaryLayers( getPixelOffset: [0, 10], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts index 3095496..cc8f3a2 100644 --- a/frontend/src/hooks/layers/createNavigationLayers.ts +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -174,7 +174,7 @@ export function createNavigationLayers( if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; }, - getSize: 8 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -182,8 +182,9 @@ export function createNavigationLayers( getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -224,7 +225,7 @@ export function createNavigationLayers( data: KOREAN_AIRPORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -232,8 +233,9 @@ export function createNavigationLayers( getPixelOffset: [0, 10], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -273,7 +275,7 @@ export function createNavigationLayers( data: NAV_WARNINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.id, - getSize: 8 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -281,8 +283,9 @@ export function createNavigationLayers( getPixelOffset: [0, 9], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -323,7 +326,7 @@ export function createNavigationLayers( data: PIRACY_ZONES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo, - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -331,8 +334,9 @@ export function createNavigationLayers( getPixelOffset: [0, 14], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts index 5629d44..c9e27e4 100644 --- a/frontend/src/hooks/layers/createPortLayers.ts +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -95,7 +95,7 @@ export function createPortLayers( data: EAST_ASIA_PORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('항', ''), - getSize: 9 * sc, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -103,8 +103,9 @@ export function createPortLayers( getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -132,7 +133,7 @@ export function createPortLayers( 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, + getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], getTextAnchor: 'middle', @@ -140,8 +141,9 @@ export function createPortLayers( getPixelOffset: [0, 10], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index bf38f58..decc92d 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -123,15 +123,16 @@ export function useAnalysisDeckLayers( const name = d.ship.name || d.ship.mmsi; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; }, - getSize: 10 * sizeScale, + getSize: 13 * sizeScale, updateTriggers: { getSize: [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], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -166,15 +167,16 @@ export function useAnalysisDeckLayers( const gap = d.dto.algorithms.darkVessel.gapDurationMin; return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK'; }, - getSize: 10 * sizeScale, + getSize: 13 * sizeScale, updateTriggers: { getSize: [sizeScale] }, getColor: [168, 85, 247, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], fontFamily: 'monospace', - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), @@ -189,14 +191,15 @@ export function useAnalysisDeckLayers( data: spoofData, getPosition: (d) => [d.ship.lng, d.ship.lat], getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, - getSize: 10 * sizeScale, + getSize: 13 * sizeScale, getColor: [239, 68, 68, 255], getTextAnchor: 'start', getPixelOffset: [12, -8], fontFamily: 'monospace', fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], + fontSettings: { sdf: true }, + outlineWidth: 8, + outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5752ef4..5be101f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -147,7 +147,7 @@ export interface LayerVisibility { meFacilities: boolean; militaryOnly: boolean; overseasUS: boolean; - overseasUK: boolean; + overseasIsrael: boolean; overseasIran: boolean; overseasUAE: boolean; overseasSaudi: boolean; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ae5e892..d81f6ba 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -110,6 +110,11 @@ export default defineConfig(({ mode }): UserConfig => ({ changeOrigin: true, secure: false, }, + '/ollama': { + target: 'http://localhost:11434', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/ollama/, ''), + }, }, }, }))