From 83c0281710e96c48af18636226ddc3bb34a40164 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:24:31 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3848b4e..43f9459 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,7 +4,26 @@ ## [Unreleased] -## [2026-03-20.3] +## [2026-03-23] + +### 추가 +- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설 +- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드) +- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴) +- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보) +- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기 + +### 변경 +- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함) +- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x) + +### 수정 +- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정 +- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소) +- deck.gl 레이어 호버 시 pointer 커서 표시 +- prediction 증분 수집 버그 수정 (vessel_store.py) + +## [2026-03-20] ### 변경 - deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL) @@ -12,22 +31,14 @@ ### 추가 - NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조 - -### 수정 -- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 - -## [2026-03-20.2] - -### 추가 -- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 +- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고 - AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 - 불법어선/다크베셀/중국어선감시 Python 분석 연동 - Backend vessel-analysis REST API + DB 테이블 복원 +- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 -## [2026-03-20] - -### 추가 -- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon) +### 수정 +- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 ## [2026-03-19] -- 2.45.2 From d6de826d1d8afd9a6302fe13e5c6eb0b5012a75b Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:02:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20Phase=201=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=E2=80=94=20IranDashboard,=20KoreaDashboard,=20Shar?= =?UTF-8?q?edFilterContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SharedFilterContext + SharedFilterProvider: 카테고리 필터 공유 상태 - useSharedFilters: Context 소비 훅 (react-refresh 호환 분리) - IranDashboard: 이란 전용 상태/JSX 추출 (274줄) - KoreaDashboard: 한국 전용 상태/JSX 추출 (323줄) - App.tsx 통합은 후속 커밋 (헤더 상태 참조 리팩토링 필요) --- .../src/components/iran/IranDashboard.tsx | 274 +++++++++++++++ .../src/components/korea/KoreaDashboard.tsx | 323 ++++++++++++++++++ frontend/src/contexts/SharedFilterContext.tsx | 32 ++ frontend/src/contexts/sharedFilterState.ts | 10 + frontend/src/hooks/useSharedFilters.ts | 11 + 5 files changed, 650 insertions(+) create mode 100644 frontend/src/components/iran/IranDashboard.tsx create mode 100644 frontend/src/components/korea/KoreaDashboard.tsx create mode 100644 frontend/src/contexts/SharedFilterContext.tsx create mode 100644 frontend/src/contexts/sharedFilterState.ts create mode 100644 frontend/src/hooks/useSharedFilters.ts diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx new file mode 100644 index 0000000..f40a4cf --- /dev/null +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -0,0 +1,274 @@ +import { useState, useCallback } from 'react'; +import { ReplayMap } from './ReplayMap'; +import type { FlyToTarget } from './ReplayMap'; +import { GlobeMap } from './GlobeMap'; +import { SatelliteMap } from './SatelliteMap'; +import { SensorChart } from '../common/SensorChart'; +import { EventLog } from '../common/EventLog'; +import { LayerPanel } from '../common/LayerPanel'; +import { LiveControls } from '../common/LiveControls'; +import { ReplayControls } from '../common/ReplayControls'; +import { TimelineSlider } from '../common/TimelineSlider'; +import { useIranData } from '../../hooks/useIranData'; +import { useSharedFilters } from '../../hooks/useSharedFilters'; +import type { GeoEvent, LayerVisibility, AppMode } from '../../types'; +import { useTranslation } from 'react-i18next'; + +interface IranDashboardProps { + currentTime: number; + isLive: boolean; + refreshKey: number; + replay: { + state: { + isPlaying: boolean; + speed: number; + startTime: number; + endTime: number; + currentTime: number; + }; + play: () => void; + pause: () => void; + reset: () => void; + setSpeed: (s: number) => void; + setRange: (s: number, e: number) => void; + seek: (t: number) => void; + }; + monitor: { + state: { currentTime: number; historyMinutes: number }; + setHistoryMinutes: (m: number) => void; + }; + timeZone: 'KST' | 'UTC'; + onTimeZoneChange: (tz: 'KST' | 'UTC') => void; +} + +const INITIAL_LAYERS: LayerVisibility = { + events: true, + aircraft: true, + satellites: true, + ships: true, + koreanShips: true, + airports: true, + sensorCharts: false, + oilFacilities: true, + meFacilities: true, + militaryOnly: false, + overseasUS: false, + overseasUK: false, + overseasIran: false, + overseasUAE: false, + overseasSaudi: false, + overseasOman: false, + overseasQatar: false, + overseasKuwait: false, + overseasIraq: false, + overseasBahrain: false, +}; + +const appModeFromIsLive = (isLive: boolean): AppMode => (isLive ? 'live' : 'replay'); + +const IranDashboard = ({ + currentTime, + isLive, + refreshKey, + replay, + monitor, + timeZone, + onTimeZoneChange, +}: IranDashboardProps) => { + const { t } = useTranslation(); + + const [mapMode, _setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); + const [layers, setLayers] = useState(INITIAL_LAYERS); + const [seismicMarker, setSeismicMarker] = useState<{ + lat: number; + lng: number; + magnitude: number; + place: string; + } | null>(null); + const [flyToTarget, setFlyToTarget] = useState(null); + const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); + const [focusShipMmsi, setFocusShipMmsi] = useState(null); + + const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = + useSharedFilters(); + + const iranData = useIranData({ + appMode: appModeFromIsLive(isLive), + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, + dashboardTab: 'iran', + }); + + const toggleLayer = useCallback((key: keyof LayerVisibility) => { + setLayers(prev => ({ ...prev, [key]: !prev[key] })); + }, []); + + const handleEventFlyTo = useCallback((event: GeoEvent) => { + setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); + }, []); + + return ( + <> +
+
+ {mapMode === 'flat' ? ( + setFlyToTarget(null)} + hoveredShipMmsi={hoveredShipMmsi} + focusShipMmsi={focusShipMmsi} + onFocusShipClear={() => setFocusShipMmsi(null)} + seismicMarker={seismicMarker} + /> + ) : mapMode === 'globe' ? ( + + ) : ( + setFocusShipMmsi(null)} + flyToTarget={flyToTarget} + onFlyToDone={() => setFlyToTarget(null)} + seismicMarker={seismicMarker} + /> + )} +
+ } + onToggle={toggleLayer as (key: string) => void} + aircraftByCategory={iranData.aircraftByCategory} + aircraftTotal={iranData.aircraft.length} + shipsByMtCategory={iranData.shipsByCategory} + shipTotal={iranData.ships.length} + satelliteCount={iranData.satPositions.length} + 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: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, + ]} + overseasItems={[ + { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' }, + { key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' }, + { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' }, + { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' }, + { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' }, + { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' }, + { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' }, + { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' }, + { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' }, + { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' }, + ]} + hiddenAcCategories={hiddenAcCategories} + hiddenShipCategories={hiddenShipCategories} + onAcCategoryToggle={toggleAcCategory} + onShipCategoryToggle={toggleShipCategory} + /> +
+
+ + +
+ + {layers.sensorCharts && ( +
+ { + setFlyToTarget({ lat, lng, zoom: 8 }); + setSeismicMarker({ lat, lng, magnitude, place }); + }} + /> +
+ )} + +
+ {isLive ? ( + + ) : ( + <> + + + + )} +
+ + ); +}; + +export { IranDashboard }; +export type { IranDashboardProps }; diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx new file mode 100644 index 0000000..190a45c --- /dev/null +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -0,0 +1,323 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KoreaMap } from './KoreaMap'; +import { FieldAnalysisModal } from './FieldAnalysisModal'; +import { LayerPanel } from '../common/LayerPanel'; +import { EventLog } from '../common/EventLog'; +import { LiveControls } from '../common/LiveControls'; +import { ReplayControls } from '../common/ReplayControls'; +import { TimelineSlider } from '../common/TimelineSlider'; +import { useKoreaData } from '../../hooks/useKoreaData'; +import { useVesselAnalysis } from '../../hooks/useVesselAnalysis'; +import { useKoreaFilters } from '../../hooks/useKoreaFilters'; +import { useSharedFilters } from '../../hooks/useSharedFilters'; +import { EAST_ASIA_PORTS } from '../../data/ports'; +import { KOREAN_AIRPORTS } from '../../services/airports'; +import { MILITARY_BASES } from '../../data/militaryBases'; +import { GOV_BUILDINGS } from '../../data/govBuildings'; +import { KOREA_WIND_FARMS } from '../../data/windFarms'; +import { NK_LAUNCH_SITES } from '../../data/nkLaunchSites'; +import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents'; +import { COAST_GUARD_FACILITIES } from '../../services/coastGuard'; +import { NAV_WARNINGS } from '../../services/navWarning'; +import { PIRACY_ZONES } from '../../services/piracy'; +import { KOREA_SUBMARINE_CABLES } from '../../services/submarineCable'; +import { HAZARD_FACILITIES } from '../../data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities'; + +type DashboardTab = 'iran' | 'korea'; + +interface ReplayState { + isPlaying: boolean; + speed: number; + startTime: number; + endTime: number; + currentTime: number; +} + +interface ReplayControls { + state: ReplayState; + play: () => void; + pause: () => void; + reset: () => void; + setSpeed: (s: number) => void; + setRange: (s: number, e: number) => void; + seek: (t: number) => void; +} + +interface MonitorState { + currentTime: number; + historyMinutes: number; +} + +interface MonitorControls { + state: MonitorState; + setHistoryMinutes: (m: number) => void; +} + +export interface KoreaDashboardProps { + currentTime: number; + isLive: boolean; + refreshKey: number; + replay: ReplayControls; + monitor: MonitorControls; + timeZone: 'KST' | 'UTC'; + onTimeZoneChange: (tz: 'KST' | 'UTC') => void; + showFieldAnalysis: boolean; + onFieldAnalysisClose: () => void; +} + +const KoreaDashboard = ({ + currentTime, + isLive, + refreshKey, + replay, + monitor, + timeZone, + onTimeZoneChange, + showFieldAnalysis, + onFieldAnalysisClose, +}: KoreaDashboardProps) => { + const { t } = useTranslation(); + + const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = + useSharedFilters(); + + const [koreaLayers, setKoreaLayers] = useState>({ + ships: true, + aircraft: true, + satellites: true, + infra: true, + cables: true, + cctv: true, + airports: true, + coastGuard: true, + navWarning: true, + osint: true, + eez: true, + piracy: true, + windFarm: true, + ports: true, + militaryBases: true, + govBuildings: true, + nkLaunch: true, + nkMissile: true, + cnFishing: false, + militaryOnly: false, + overseasChina: false, + overseasJapan: false, + cnPower: false, + cnMilitary: false, + jpPower: false, + jpMilitary: false, + hazardPetrochemical: false, + hazardLng: false, + hazardOilTank: false, + hazardPort: false, + energyNuclear: false, + energyThermal: false, + industryShipyard: false, + industryWastewater: false, + industryHeavy: false, + }); + + const toggleKoreaLayer = useCallback((key: string) => { + setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); + }, []); + + const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); + const toggleNationality = useCallback((nat: string) => { + setHiddenNationalities(prev => { + const next = new Set(prev); + if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } + return next; + }); + }, []); + + const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); + const toggleFishingNat = useCallback((nat: string) => { + setHiddenFishingNats(prev => { + const next = new Set(prev); + if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } + return next; + }); + }, []); + + const koreaData = useKoreaData({ + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + hiddenNationalities, + refreshKey, + }); + + const vesselAnalysis = useVesselAnalysis(true); + + const koreaFiltersResult = useKoreaFilters( + koreaData.ships, + koreaData.visibleShips, + currentTime, + vesselAnalysis.analysisMap, + ); + + const handleTabChange = useCallback((_tab: DashboardTab) => { + // Tab switching is managed by parent (App.tsx); no-op here + }, []); + + return ( + <> +
+
+ {showFieldAnalysis && ( + + )} + +
+ f.type === 'nuclear').length, group: '에너지/발전시설' }, + { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' }, + // 위험시설 + { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' }, + { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' }, + { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' }, + { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' }, + // 산업공정/제조시설 + { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' }, + { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' }, + { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' }, + ]} + overseasItems={[ + { + key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444', + count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length, + children: [ + { key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length }, + { key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length }, + ], + }, + { + key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6', + count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length, + children: [ + { key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length }, + { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length }, + ], + }, + ]} + hiddenAcCategories={hiddenAcCategories} + hiddenShipCategories={hiddenShipCategories} + onAcCategoryToggle={toggleAcCategory} + onShipCategoryToggle={toggleShipCategory} + shipsByNationality={koreaData.shipsByNationality} + hiddenNationalities={hiddenNationalities} + onNationalityToggle={toggleNationality} + fishingByNationality={koreaData.fishingByNationality} + hiddenFishingNats={hiddenFishingNats} + onFishingNatToggle={toggleFishingNat} + /> +
+
+ + +
+ +
+ {isLive ? ( + + ) : ( + <> + + undefined} + /> + + )} +
+ + ); +}; + +export default KoreaDashboard; diff --git a/frontend/src/contexts/SharedFilterContext.tsx b/frontend/src/contexts/SharedFilterContext.tsx new file mode 100644 index 0000000..d89cb63 --- /dev/null +++ b/frontend/src/contexts/SharedFilterContext.tsx @@ -0,0 +1,32 @@ +import { useState, useCallback } from 'react'; +import { SharedFilterContext } from './sharedFilterState'; + +export { SharedFilterContext } from './sharedFilterState'; +export type { SharedFilterState } from './sharedFilterState'; + +export function SharedFilterProvider({ children }: { children: React.ReactNode }) { + const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); + const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); + + const toggleAcCategory = useCallback((cat: string) => { + setHiddenAcCategories(prev => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); else next.add(cat); + return next; + }); + }, []); + + const toggleShipCategory = useCallback((cat: string) => { + setHiddenShipCategories(prev => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); else next.add(cat); + return next; + }); + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/contexts/sharedFilterState.ts b/frontend/src/contexts/sharedFilterState.ts new file mode 100644 index 0000000..47fcf3e --- /dev/null +++ b/frontend/src/contexts/sharedFilterState.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +export interface SharedFilterState { + hiddenAcCategories: Set; + hiddenShipCategories: Set; + toggleAcCategory: (cat: string) => void; + toggleShipCategory: (cat: string) => void; +} + +export const SharedFilterContext = createContext(null); diff --git a/frontend/src/hooks/useSharedFilters.ts b/frontend/src/hooks/useSharedFilters.ts new file mode 100644 index 0000000..6b9609e --- /dev/null +++ b/frontend/src/hooks/useSharedFilters.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SharedFilterContext } from '../contexts/sharedFilterState'; +import type { SharedFilterState } from '../contexts/sharedFilterState'; + +export type { SharedFilterState }; + +export function useSharedFilters(): SharedFilterState { + const ctx = useContext(SharedFilterContext); + if (!ctx) throw new Error('useSharedFilters must be inside SharedFilterProvider'); + return ctx; +} -- 2.45.2 From 19e5ff23aad9190c50d507e13e0bed0d07840a4c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:06:58 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20Phase=201=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=E2=80=94=20App.tsx=20=EB=B6=84=ED=95=B4=20(771?= =?UTF-8?q?=EC=A4=84=E2=86=92163=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: 탭 전환 + 공통 헤더(MON/LANG/THEME) + SharedFilterProvider만 유지 - IranDashboard: 이란 전용 상태/JSX + 헤더 Portal (모드토글/맵모드/카운트) - KoreaDashboard: 한국 전용 상태/JSX + 헤더 Portal (필터버튼/카운트) - SharedFilterContext: hiddenAcCategories/hiddenShipCategories 공유 상태 - useSharedFilters: Context 소비 훅 (react-refresh 호환) - showFieldAnalysis를 KoreaDashboard 내부로 이동 - 헤더 슬롯(dashboard-header-slot/dashboard-counts-slot)으로 탭별 UI Portal --- frontend/src/App.tsx | 780 ++---------------- .../src/components/iran/IranDashboard.tsx | 57 +- .../src/components/korea/KoreaDashboard.tsx | 58 +- 3 files changed, 192 insertions(+), 703 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94524a..d97bd9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,43 +1,15 @@ import { useState, useEffect, useCallback } from 'react'; -import { ReplayMap } from './components/iran/ReplayMap'; -import type { FlyToTarget } from './components/iran/ReplayMap'; -import { GlobeMap } from './components/iran/GlobeMap'; -import { SatelliteMap } from './components/iran/SatelliteMap'; -import { KoreaMap } from './components/korea/KoreaMap'; -import { TimelineSlider } from './components/common/TimelineSlider'; -import { ReplayControls } from './components/common/ReplayControls'; -import { LiveControls } from './components/common/LiveControls'; -import { SensorChart } from './components/common/SensorChart'; -import { EventLog } from './components/common/EventLog'; -import { LayerPanel } from './components/common/LayerPanel'; import { useReplay } from './hooks/useReplay'; import { useMonitor } from './hooks/useMonitor'; -import { useIranData } from './hooks/useIranData'; -import { useKoreaData } from './hooks/useKoreaData'; -import { useKoreaFilters } from './hooks/useKoreaFilters'; -import { useVesselAnalysis } from './hooks/useVesselAnalysis'; -import type { GeoEvent, LayerVisibility, AppMode } from './types'; +import type { AppMode } from './types'; import { useTheme } from './hooks/useTheme'; import { useAuth } from './hooks/useAuth'; import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; -import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; -// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨) -import { EAST_ASIA_PORTS } from './data/ports'; -import { KOREAN_AIRPORTS } from './services/airports'; -import { MILITARY_BASES } from './data/militaryBases'; -import { GOV_BUILDINGS } from './data/govBuildings'; -import { KOREA_WIND_FARMS } from './data/windFarms'; -import { NK_LAUNCH_SITES } from './data/nkLaunchSites'; -import { NK_MISSILE_EVENTS } from './data/nkMissileEvents'; -import { COAST_GUARD_FACILITIES } from './services/coastGuard'; -import { NAV_WARNINGS } from './services/navWarning'; -import { PIRACY_ZONES } from './services/piracy'; -import { KOREA_SUBMARINE_CABLES } from './services/submarineCable'; -import { HAZARD_FACILITIES } from './data/hazardFacilities'; -import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities'; -import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities'; +import { SharedFilterProvider } from './contexts/SharedFilterContext'; +import { IranDashboard } from './components/iran/IranDashboard'; +import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; function App() { @@ -68,133 +40,18 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); - const [layers, setLayers] = useState({ - events: true, - aircraft: true, - satellites: true, - ships: true, - koreanShips: true, - airports: true, - sensorCharts: false, - oilFacilities: true, - meFacilities: true, - militaryOnly: false, - overseasUS: false, - overseasUK: false, - overseasIran: false, - overseasUAE: false, - overseasSaudi: false, - overseasOman: false, - overseasQatar: false, - overseasKuwait: false, - overseasIraq: false, - overseasBahrain: false, - }); - - // Korea tab layer visibility (lifted from KoreaMap) - const [koreaLayers, setKoreaLayers] = useState>({ - ships: true, - aircraft: true, - satellites: true, - infra: true, - cables: true, - cctv: true, - airports: true, - coastGuard: true, - navWarning: true, - osint: true, - eez: true, - piracy: true, - windFarm: true, - ports: true, - militaryBases: true, - govBuildings: true, - nkLaunch: true, - nkMissile: true, - cnFishing: false, - militaryOnly: false, - overseasChina: false, - overseasJapan: false, - cnPower: false, - cnMilitary: false, - jpPower: false, - jpMilitary: false, - hazardPetrochemical: false, - hazardLng: false, - hazardOilTank: false, - hazardPort: false, - energyNuclear: false, - energyThermal: false, - industryShipyard: false, - industryWastewater: false, - industryHeavy: false, - }); - - const toggleKoreaLayer = useCallback((key: string) => { - setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); - - // Category filter state (shared across tabs) - const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); - const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); - - const toggleAcCategory = useCallback((cat: string) => { - setHiddenAcCategories(prev => { - const next = new Set(prev); - if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } - return next; - }); - }, []); - - const toggleShipCategory = useCallback((cat: string) => { - setHiddenShipCategories(prev => { - const next = new Set(prev); - if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } - return next; - }); - }, []); - - // Nationality filter state (Korea tab) - const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); - const toggleNationality = useCallback((nat: string) => { - setHiddenNationalities(prev => { - const next = new Set(prev); - if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } - return next; - }); - }, []); - - // Fishing vessel nationality filter state - const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); - const toggleFishingNat = useCallback((nat: string) => { - setHiddenFishingNats(prev => { - const next = new Set(prev); - if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } - return next; - }); - }, []); - - const [flyToTarget, setFlyToTarget] = useState(null); + const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); // 1시간마다 전체 데이터 강제 리프레시 const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { const HOUR_MS = 3600_000; - const interval = setInterval(() => { - setRefreshKey(k => k + 1); - }, HOUR_MS); + const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS); return () => clearInterval(interval); }, []); - const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); - const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); - const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); - const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); - const [focusShipMmsi, setFocusShipMmsi] = useState(null); - const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null); - const replay = useReplay(); const monitor = useMonitor(); const { theme, toggleTheme } = useTheme(); @@ -204,567 +61,102 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, [i18n]); const isLive = appMode === 'live'; - - // Unified time values based on current mode - const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime; - - // Iran data hook - const iranData = useIranData({ - appMode, - currentTime, - isLive, - hiddenAcCategories, - hiddenShipCategories, - refreshKey, - dashboardTab, - }); - - // Korea data hook - const koreaData = useKoreaData({ - currentTime, - isLive, - hiddenAcCategories, - hiddenShipCategories, - hiddenNationalities, - refreshKey, - }); - - // Vessel analysis (Python prediction 결과) - const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea'); - - // Korea filters hook - const koreaFiltersResult = useKoreaFilters( - koreaData.ships, - koreaData.visibleShips, - currentTime, - vesselAnalysis.analysisMap, - ); - - const toggleLayer = useCallback((key: keyof LayerVisibility) => { - setLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); - - // Handle event card click from timeline: fly to location on map - const handleEventFlyTo = useCallback((event: GeoEvent) => { - setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); - }, []); + const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime; return ( -
-
- {/* Dashboard Tabs (replaces title) */} -
- - -
- - {/* Mode Toggle */} - {dashboardTab === 'iran' && ( -
-
- ⚔️ - D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} -
+ +
+
+ {/* Dashboard Tabs */} +
+ + {/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */} +
+ +
+
+
+ + + +
+
+ + {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')} +
+ {user && ( +
+ {user.picture && ( + + )} + {user.name} + +
+ )} +
+
+ + {dashboardTab === 'iran' && ( + )} {dashboardTab === 'korea' && ( -
- - - - - - - - -
+ )} - {dashboardTab === 'iran' && ( -
- - - -
+ {showCollectorMonitor && ( + setShowCollectorMonitor(false)} /> )} - -
-
- {dashboardTab === 'iran' ? iranData.aircraft.length : koreaData.aircraft.length} AC - {dashboardTab === 'iran' ? iranData.militaryCount : koreaData.militaryCount} MIL - {dashboardTab === 'iran' ? iranData.ships.length : koreaData.ships.length} SHIP - {dashboardTab === 'iran' ? iranData.satPositions.length : koreaData.satPositions.length} SAT -
-
- - - -
-
- - {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')} -
- {user && ( -
- {user.picture && ( - - )} - {user.name} - -
- )} -
-
- - {/* ═══════════════════════════════════════ - IRAN DASHBOARD - ═══════════════════════════════════════ */} - {dashboardTab === 'iran' && ( - <> -
-
- {mapMode === 'flat' ? ( - setFlyToTarget(null)} - hoveredShipMmsi={hoveredShipMmsi} - focusShipMmsi={focusShipMmsi} - onFocusShipClear={() => setFocusShipMmsi(null)} - seismicMarker={seismicMarker} - /> - ) : mapMode === 'globe' ? ( - - ) : ( - setFocusShipMmsi(null)} - flyToTarget={flyToTarget} - onFlyToDone={() => setFlyToTarget(null)} - seismicMarker={seismicMarker} - /> - )} -
- } - onToggle={toggleLayer as (key: string) => void} - aircraftByCategory={iranData.aircraftByCategory} - aircraftTotal={iranData.aircraft.length} - shipsByMtCategory={iranData.shipsByCategory} - shipTotal={iranData.ships.length} - satelliteCount={iranData.satPositions.length} - 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: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, - ]} - overseasItems={[ - { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' }, - { key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' }, - { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' }, - { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' }, - { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' }, - { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' }, - { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' }, - { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' }, - { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' }, - { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' }, - ]} - hiddenAcCategories={hiddenAcCategories} - hiddenShipCategories={hiddenShipCategories} - onAcCategoryToggle={toggleAcCategory} - onShipCategoryToggle={toggleShipCategory} - /> -
-
- - -
- - {layers.sensorCharts && ( -
- { - setFlyToTarget({ lat, lng, zoom: 8 }); - setSeismicMarker({ lat, lng, magnitude, place }); - }} - /> -
- )} - -
- {isLive ? ( - - ) : ( - <> - - - - )} - -
- - )} - - {/* ═══════════════════════════════════════ - KOREA DASHBOARD - ═══════════════════════════════════════ */} - {dashboardTab === 'korea' && ( - <> -
-
- {showFieldAnalysis && ( - setShowFieldAnalysis(false)} /> - )} - -
- f.type === 'nuclear').length, group: '에너지/발전시설' }, - { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' }, - // 위험시설 - { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' }, - { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' }, - { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' }, - { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' }, - // 산업공정/제조시설 - { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' }, - { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' }, - { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' }, - ]} - overseasItems={[ - { - key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444', - count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length, - children: [ - { key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length }, - { key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length }, - ], - }, - { - key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6', - count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length, - children: [ - { key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length }, - { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length }, - ], - }, - ]} - hiddenAcCategories={hiddenAcCategories} - hiddenShipCategories={hiddenShipCategories} - onAcCategoryToggle={toggleAcCategory} - onShipCategoryToggle={toggleShipCategory} - shipsByNationality={koreaData.shipsByNationality} - hiddenNationalities={hiddenNationalities} - onNationalityToggle={toggleNationality} - fishingByNationality={koreaData.fishingByNationality} - hiddenFishingNats={hiddenFishingNats} - onFishingNatToggle={toggleFishingNat} - /> -
-
- - -
- -
- {isLive ? ( - - ) : ( - <> - - - - )} -
- - )} - - {showCollectorMonitor && ( - setShowCollectorMonitor(false)} /> - )} -
+ + ); } diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index f40a4cf..ae63a59 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { ReplayMap } from './ReplayMap'; import type { FlyToTarget } from './ReplayMap'; import { GlobeMap } from './GlobeMap'; @@ -39,6 +40,8 @@ interface IranDashboardProps { }; timeZone: 'KST' | 'UTC'; onTimeZoneChange: (tz: 'KST' | 'UTC') => void; + appMode: AppMode; + onAppModeChange: (mode: AppMode) => void; } const INITIAL_LAYERS: LayerVisibility = { @@ -64,8 +67,6 @@ const INITIAL_LAYERS: LayerVisibility = { overseasBahrain: false, }; -const appModeFromIsLive = (isLive: boolean): AppMode => (isLive ? 'live' : 'replay'); - const IranDashboard = ({ currentTime, isLive, @@ -74,10 +75,12 @@ const IranDashboard = ({ monitor, timeZone, onTimeZoneChange, + appMode, + onAppModeChange, }: IranDashboardProps) => { const { t } = useTranslation(); - const [mapMode, _setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); + const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [layers, setLayers] = useState(INITIAL_LAYERS); const [seismicMarker, setSeismicMarker] = useState<{ lat: number; @@ -93,7 +96,7 @@ const IranDashboard = ({ useSharedFilters(); const iranData = useIranData({ - appMode: appModeFromIsLive(isLive), + appMode, currentTime, isLive, hiddenAcCategories, @@ -110,8 +113,54 @@ const IranDashboard = ({ setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); + // 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트 + const headerSlot = document.getElementById('dashboard-header-slot'); + const countsSlot = document.getElementById('dashboard-counts-slot'); + return ( <> + {headerSlot && createPortal( + <> +
+
+ ⚔️ + D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} +
+ + +
+
+ + + +
+ , + headerSlot, + )} + {countsSlot && createPortal( +
+ {iranData.aircraft.length} AC + {iranData.militaryCount} MIL + {iranData.ships.length} SHIP + {iranData.satPositions.length} SAT +
, + countsSlot, + )}
{mapMode === 'flat' ? ( diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 190a45c..3482521 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { KoreaMap } from './KoreaMap'; import { FieldAnalysisModal } from './FieldAnalysisModal'; @@ -64,8 +65,6 @@ export interface KoreaDashboardProps { monitor: MonitorControls; timeZone: 'KST' | 'UTC'; onTimeZoneChange: (tz: 'KST' | 'UTC') => void; - showFieldAnalysis: boolean; - onFieldAnalysisClose: () => void; } const KoreaDashboard = ({ @@ -76,9 +75,8 @@ const KoreaDashboard = ({ monitor, timeZone, onTimeZoneChange, - showFieldAnalysis, - onFieldAnalysisClose, }: KoreaDashboardProps) => { + const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); const { t } = useTranslation(); const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = @@ -166,15 +164,65 @@ const KoreaDashboard = ({ // Tab switching is managed by parent (App.tsx); no-op here }, []); + // 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트 + const headerSlot = document.getElementById('dashboard-header-slot'); + const countsSlot = document.getElementById('dashboard-counts-slot'); + return ( <> + {headerSlot && createPortal( +
+ + + + + + + + +
, + headerSlot, + )} + {countsSlot && createPortal( +
+ {koreaData.aircraft.length} AC + {koreaData.militaryCount} MIL + {koreaData.ships.length} SHIP + {koreaData.satPositions.length} SAT +
, + countsSlot, + )}
{showFieldAnalysis && ( setShowFieldAnalysis(false)} /> )} Date: Mon, 23 Mar 2026 10:14:54 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20Phase=202=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=E2=80=94=20useStaticDeckLayers=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20(1,086=EC=A4=84=E2=86=9285=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useStaticDeckLayers를 조합 훅으로 전환 (서브훅 호출만) - createPortLayers: 항구 + 풍력단지 (145줄) - createNavigationLayers: 해경 + 공항 + 항행경보 + 해적 (332줄) - createMilitaryLayers: 군사시설 + 정부기관 + NK 발사/미사일 (272줄) - createFacilityLayers: 인프라 + 위험시설 + CN/JP 시설 (310줄) - layers/types.ts: 공유 타입 + hexToRgb (49줄) - 각 서브훅은 SVG/색상 상수를 자체 포함 (독립 모듈) --- .../src/hooks/layers/createFacilityLayers.ts | 310 +++++ .../src/hooks/layers/createMilitaryLayers.ts | 272 +++++ .../hooks/layers/createNavigationLayers.ts | 332 +++++ frontend/src/hooks/layers/createPortLayers.ts | 145 +++ frontend/src/hooks/layers/types.ts | 49 + frontend/src/hooks/useStaticDeckLayers.ts | 1071 +---------------- 6 files changed, 1143 insertions(+), 1036 deletions(-) create mode 100644 frontend/src/hooks/layers/createFacilityLayers.ts create mode 100644 frontend/src/hooks/layers/createMilitaryLayers.ts create mode 100644 frontend/src/hooks/layers/createNavigationLayers.ts create mode 100644 frontend/src/hooks/layers/createPortLayers.ts create mode 100644 frontend/src/hooks/layers/types.ts diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts new file mode 100644 index 0000000..556ec99 --- /dev/null +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -0,0 +1,310 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { HAZARD_FACILITIES } from '../../data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities'; +import { + hexToRgb, + type LayerFactoryConfig, + type Layer, + type PickingInfo, + type PowerFacility, + type HazardFacility, + type HazardType, + type CnFacility, + type JpFacility, +} from './types'; + +// ─── Infra SVG ──────────────────────────────────────────────────────────────── + +const INFRA_SOURCE_COLOR: Record = { + nuclear: '#e040fb', + coal: '#795548', + gas: '#ff9800', + oil: '#5d4037', + hydro: '#2196f3', + solar: '#ffc107', + wind: '#00bcd4', + biomass: '#4caf50', +}; +const INFRA_SUBSTATION_COLOR = '#ffeb3b'; + +const WIND_COLOR = '#00bcd4'; + +function windTurbineSvg(size: number): string { + return ` + + + + + + + + `; +} + +function infraColor(f: PowerFacility): string { + if (f.type === 'substation') return INFRA_SUBSTATION_COLOR; + return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e'; +} + +function infraSvg(f: PowerFacility): string { + const color = infraColor(f); + if (f.source === 'wind') { + return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`); + } + const size = f.type === 'substation' ? 7 : 12; + return ` + + `; +} + +// ─── createFacilityLayers ───────────────────────────────────────────────────── + +export function createFacilityLayers( + config: { + infra: boolean; + infraFacilities: PowerFacility[]; + hazardTypes: HazardType[]; + cnPower: boolean; + cnMilitary: boolean; + jpPower: boolean; + jpMilitary: boolean; + }, + fc: LayerFactoryConfig, +): Layer[] { + const layers: Layer[] = []; + const sc = fc.sc; + const onPick = fc.onPick; + + // ── Infra ────────────────────────────────────────────────────────────── + if (config.infra && config.infraFacilities.length > 0) { + const infraIconCache = new Map(); + function getInfraIconUrl(f: PowerFacility): string { + const key = `${f.type}-${f.source ?? ''}`; + if (!infraIconCache.has(key)) { + infraIconCache.set(key, svgToDataUri(infraSvg(f))); + } + return infraIconCache.get(key)!; + } + + const plants = config.infraFacilities.filter(f => f.type === 'plant'); + const substations = config.infraFacilities.filter(f => f.type === 'substation'); + + layers.push( + new IconLayer({ + id: 'static-infra-substation', + data: substations, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), + getSize: 7 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new IconLayer({ + id: 'static-infra-plant', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), + getSize: 12 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-infra-plant-label', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Hazard Facilities ────────────────────────────────────────────────── + if (config.hazardTypes.length > 0) { + const hazardTypeSet = new Set(config.hazardTypes); + const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); + + const HAZARD_META: Record = { + petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, + lng: { icon: '🔵', color: [6, 182, 212, 255] }, + oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, + hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, + nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, + thermal: { icon: '🔥', color: [100, 116, 139, 255] }, + shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, + wastewater: { icon: '💧', color: [16, 185, 129, 255] }, + heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, + }; + + if (hazardData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-hazard-emoji', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', + getSize: 16 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'hazard', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-hazard-label', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 9 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── CN Facilities ────────────────────────────────────────────────────── + { + const CN_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, + }; + const cnData: CnFacility[] = [ + ...(config.cnPower ? CN_POWER_PLANTS : []), + ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), + ]; + if (cnData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-cn-emoji', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => CN_META[d.subType]?.icon ?? '📍', + getSize: 16 * sc, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'cnFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-cn-label', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * sc, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── JP Facilities ────────────────────────────────────────────────────── + { + const JP_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + }; + const jpData: JpFacility[] = [ + ...(config.jpPower ? JP_POWER_PLANTS : []), + ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), + ]; + if (jpData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-jp-emoji', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => JP_META[d.subType]?.icon ?? '📍', + getSize: 16 * sc, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'jpFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-jp-label', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * sc, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts new file mode 100644 index 0000000..5ab8570 --- /dev/null +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -0,0 +1,272 @@ +import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { MILITARY_BASES } from '../../data/militaryBases'; +import type { MilitaryBase } from '../../data/militaryBases'; +import { GOV_BUILDINGS } from '../../data/govBuildings'; +import type { GovBuilding } from '../../data/govBuildings'; +import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites'; +import type { NKLaunchSite } from '../../data/nkLaunchSites'; +import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents'; +import type { NKMissileEvent } from '../../data/nkMissileEvents'; +import { hexToRgb } from './types'; +import type { LayerFactoryConfig } from './types'; + +// ─── NKMissile SVG ──────────────────────────────────────────────────────────── + +function getMissileColor(type: string): string { + if (type.includes('ICBM')) return '#dc2626'; + if (type.includes('IRBM')) return '#ef4444'; + if (type.includes('SLBM')) return '#3b82f6'; + return '#f97316'; +} + +function missileLaunchSvg(color: string): string { + return ` + + `; +} + +function missileImpactSvg(color: string): string { + return ` + + + + `; +} + +export function createMilitaryLayers( + config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean }, + fc: LayerFactoryConfig, +): Layer[] { + const layers: Layer[] = []; + const sc = fc.sc; + const onPick = fc.onPick; + + // ── Military Bases — TextLayer (이모지) ─────────────────────────────── + if (config.militaryBases) { + const TYPE_COLOR: Record = { + naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', + missile: '#ef4444', joint: '#a78bfa', + }; + const TYPE_ICON: Record = { + naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', + }; + layers.push( + new TextLayer({ + id: 'static-militarybase-emoji', + data: MILITARY_BASES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => TYPE_ICON[d.type] ?? '⭐', + getSize: 14 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'militaryBase', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-militarybase-label', + data: MILITARY_BASES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 9], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── + if (config.govBuildings) { + const GOV_TYPE_COLOR: Record = { + executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', + intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', + }; + const GOV_TYPE_ICON: Record = { + executive: '🏛', legislature: '🏛', military_hq: '⭐', + intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', + }; + layers.push( + new TextLayer({ + id: 'static-govbuilding-emoji', + data: GOV_BUILDINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', + getSize: 12 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'govBuilding', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-govbuilding-label', + data: GOV_BUILDINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── + if (config.nkLaunch) { + layers.push( + new TextLayer({ + id: 'static-nklaunch-emoji', + data: NK_LAUNCH_SITES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀', + getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'nkLaunch', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-nklaunch-label', + data: NK_LAUNCH_SITES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Missile Events — IconLayer ───────────────────────────────────── + if (config.nkMissile) { + const launchIconCache = new Map(); + function getLaunchIconUrl(type: string): string { + if (!launchIconCache.has(type)) { + launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); + } + return launchIconCache.get(type)!; + } + const impactIconCache = new Map(); + function getImpactIconUrl(type: string): string { + if (!impactIconCache.has(type)) { + impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type)))); + } + return impactIconCache.get(type)!; + } + + interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number } + interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number } + + const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng })); + const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng })); + + // 발사→착탄 궤적선 + const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({ + path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][], + color: hexToRgb(getMissileColor(ev.type)), + })); + layers.push( + new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({ + id: 'static-nkmissile-trajectory', + data: trajectoryData, + getPath: (d) => d.path, + getColor: (d) => [...d.color, 150] as [number, number, number, number], + getWidth: 2, + widthUnits: 'pixels', + getDashArray: [6, 3], + dashJustified: true, + extensions: [], + }), + ); + + layers.push( + new IconLayer({ + id: 'static-nkmissile-launch', + data: launchData, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }), + getSize: 12 * sc, + getColor: (d) => { + const today = new Date().toISOString().slice(0, 10) === d.ev.date; + return [255, 255, 255, today ? 255 : 90] as [number, number, number, number]; + }, + }), + new IconLayer({ + id: 'static-nkmissile-impact', + data: impactData, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }), + getSize: 16 * sc, + getColor: (d) => { + const today = new Date().toISOString().slice(0, 10) === d.ev.date; + return [255, 255, 255, today ? 255 : 100] as [number, number, number, number]; + }, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'nkMissile', object: info.object.ev }); + return true; + }, + }), + new TextLayer({ + id: 'static-nkmissile-label', + data: impactData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts new file mode 100644 index 0000000..69a8df2 --- /dev/null +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -0,0 +1,332 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { COAST_GUARD_FACILITIES } from '../../services/coastGuard'; +import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard'; +import { KOREAN_AIRPORTS } from '../../services/airports'; +import type { KoreanAirport } from '../../services/airports'; +import { NAV_WARNINGS } from '../../services/navWarning'; +import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning'; +import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy'; +import type { PiracyZone } from '../../services/piracy'; +import { hexToRgb } from './types'; +import type { LayerFactoryConfig } from './types'; + +// ─── CoastGuard ─────────────────────────────────────────────────────────────── + +const CG_TYPE_COLOR: Record = { + hq: '#ff6b6b', + regional: '#ffa94d', + station: '#4dabf7', + substation: '#69db7c', + vts: '#da77f2', + navy: '#3b82f6', +}; + +function coastGuardSvg(type: CoastGuardType, size: number): string { + const color = CG_TYPE_COLOR[type]; + if (type === 'navy') { + return ` + + + + + `; + } + if (type === 'vts') { + return ` + + + + + `; + } + return ` + + + + + + `; +} + +const CG_TYPE_SIZE: Record = { + hq: 24, + regional: 20, + station: 16, + substation: 13, + vts: 14, + navy: 18, +}; + +// ─── Airport ────────────────────────────────────────────────────────────────── + +const AP_COUNTRY_COLOR: Record = { + KR: { intl: '#a78bfa', domestic: '#7c8aaa' }, + CN: { intl: '#ef4444', domestic: '#b91c1c' }, + JP: { intl: '#f472b6', domestic: '#9d174d' }, + KP: { intl: '#f97316', domestic: '#c2410c' }, + TW: { intl: '#10b981', domestic: '#059669' }, +}; + +function apColor(ap: KoreanAirport): string { + const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR; + return ap.intl ? cc.intl : cc.domestic; +} + +function airportSvg(color: string, size: number): string { + return ` + + + `; +} + +// ─── NavWarning ─────────────────────────────────────────────────────────────── + +const NW_ORG_COLOR: Record = { + '해군': '#8b5cf6', + '해병대': '#22c55e', + '공군': '#f97316', + '육군': '#ef4444', + '해경': '#3b82f6', + '국과연': '#eab308', +}; + +function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string { + const color = NW_ORG_COLOR[org]; + if (level === 'danger') { + return ` + + + + `; + } + return ` + + + + `; +} + +// ─── Piracy ─────────────────────────────────────────────────────────────────── + +function piracySvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +export function createNavigationLayers( + config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean }, + fc: LayerFactoryConfig, +): Layer[] { + const layers: Layer[] = []; + const sc = fc.sc; + const onPick = fc.onPick; + + // ── Coast Guard ──────────────────────────────────────────────────────── + if (config.coastGuard) { + const cgIconCache = new Map(); + function getCgIconUrl(type: CoastGuardType): string { + if (!cgIconCache.has(type)) { + const size = CG_TYPE_SIZE[type]; + cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2))); + } + return cgIconCache.get(type)!; + } + + layers.push( + new IconLayer({ + id: 'static-coastguard-icon', + data: COAST_GUARD_FACILITIES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = CG_TYPE_SIZE[d.type] * 2; + return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => CG_TYPE_SIZE[d.type] * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'coastGuard', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-coastguard-label', + data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'), + getPosition: (d) => [d.lng, d.lat], + getText: (d) => { + if (d.type === 'vts') return 'VTS'; + if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); + return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; + }, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Airports ─────────────────────────────────────────────────────────── + if (config.airports) { + const apIconCache = new Map(); + function getApIconUrl(ap: KoreanAirport): string { + const color = apColor(ap); + const size = ap.intl ? 40 : 32; + const key = `${color}-${size}`; + if (!apIconCache.has(key)) { + apIconCache.set(key, svgToDataUri(airportSvg(color, size))); + } + return apIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-airports-icon', + data: KOREAN_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.intl ? 40 : 32; + return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.intl ? 20 : 16) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'airport', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-airports-label', + data: KOREAN_AIRPORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NavWarning ───────────────────────────────────────────────────────── + if (config.navWarning) { + const nwIconCache = new Map(); + function getNwIconUrl(w: NavWarning): string { + const key = `${w.level}-${w.org}`; + if (!nwIconCache.has(key)) { + const size = w.level === 'danger' ? 32 : 28; + nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size))); + } + return nwIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-navwarning-icon', + data: NAV_WARNINGS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.level === 'danger' ? 32 : 28; + return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'navWarning', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-navwarning-label', + data: NAV_WARNINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.id, + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 9], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Piracy ───────────────────────────────────────────────────────────── + if (config.piracy) { + const piracyIconCache = new Map(); + function getPiracyIconUrl(zone: PiracyZone): string { + const key = zone.level; + if (!piracyIconCache.has(key)) { + const color = PIRACY_LEVEL_COLOR[zone.level]; + const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40; + piracyIconCache.set(key, svgToDataUri(piracySvg(color, size))); + } + return piracyIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-piracy-icon', + data: PIRACY_ZONES, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => { + const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40; + return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; + }, + getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'piracy', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-piracy-label', + data: PIRACY_ZONES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo, + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 14], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts new file mode 100644 index 0000000..a336c1b --- /dev/null +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -0,0 +1,145 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { PickingInfo, Layer } from '@deck.gl/core'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { EAST_ASIA_PORTS } from '../../data/ports'; +import type { Port } from '../../data/ports'; +import { KOREA_WIND_FARMS } from '../../data/windFarms'; +import type { WindFarm } from '../../data/windFarms'; +import { hexToRgb } from './types'; +import type { LayerFactoryConfig } from './types'; + +// ─── Port colors ────────────────────────────────────────────────────────────── + +const PORT_COUNTRY_COLOR: Record = { + KR: '#3b82f6', + CN: '#ef4444', + JP: '#f472b6', + KP: '#f97316', + TW: '#10b981', +}; + +// ─── Port SVG ──────────────────────────────────────────────────────────────── + +function portSvg(color: string, size: number): string { + return ` + + + + + `; +} + +// ─── Wind Turbine SVG ───────────────────────────────────────────────────────── + +const WIND_COLOR = '#00bcd4'; + +function windTurbineSvg(size: number): string { + return ` + + + + + + + + `; +} + +export function createPortLayers( + config: { ports: boolean; windFarm: boolean }, + fc: LayerFactoryConfig, +): Layer[] { + const layers: Layer[] = []; + const sc = fc.sc; + const onPick = fc.onPick; + + // ── Ports ─────────────────────────────────────────────────────────────── + if (config.ports) { + const portIconCache = new Map(); + function getPortIconUrl(p: Port): string { + const key = `${p.country}-${p.type}`; + if (!portIconCache.has(key)) { + const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR; + const size = p.type === 'major' ? 32 : 24; + portIconCache.set(key, svgToDataUri(portSvg(color, size))); + } + return portIconCache.get(key)!; + } + + layers.push( + new IconLayer({ + id: 'static-ports-icon', + data: EAST_ASIA_PORTS, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ + url: getPortIconUrl(d), + width: d.type === 'major' ? 32 : 24, + height: d.type === 'major' ? 32 : 24, + anchorX: d.type === 'major' ? 16 : 12, + anchorY: d.type === 'major' ? 16 : 12, + }), + getSize: (d) => (d.type === 'major' ? 16 : 12) * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'port', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-ports-label', + data: EAST_ASIA_PORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.replace('항', ''), + getSize: 9 * sc, + getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Wind Farms ───────────────────────────────────────────────────────── + if (config.windFarm) { + const windUrl = svgToDataUri(windTurbineSvg(36)); + layers.push( + new IconLayer({ + id: 'static-windfarm-icon', + data: KOREA_WIND_FARMS, + getPosition: (d) => [d.lng, d.lat], + getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }), + getSize: 18 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'windFarm', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-windfarm-label', + data: KOREA_WIND_FARMS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), + getSize: 9 * sc, + getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/types.ts b/frontend/src/hooks/layers/types.ts new file mode 100644 index 0000000..3f179ec --- /dev/null +++ b/frontend/src/hooks/layers/types.ts @@ -0,0 +1,49 @@ +import type { PickingInfo, Layer } from '@deck.gl/core'; +import type { Port } from '../../data/ports'; +import type { WindFarm } from '../../data/windFarms'; +import type { MilitaryBase } from '../../data/militaryBases'; +import type { GovBuilding } from '../../data/govBuildings'; +import type { NKLaunchSite } from '../../data/nkLaunchSites'; +import type { NKMissileEvent } from '../../data/nkMissileEvents'; +import type { CoastGuardFacility } from '../../services/coastGuard'; +import type { KoreanAirport } from '../../services/airports'; +import type { NavWarning } from '../../services/navWarning'; +import type { PiracyZone } from '../../services/piracy'; +import type { PowerFacility } from '../../services/infra'; +import type { HazardFacility, HazardType } from '../../data/hazardFacilities'; +import type { CnFacility } from '../../data/cnFacilities'; +import type { JpFacility } from '../../data/jpFacilities'; + +export type StaticPickedObject = + | Port | WindFarm | MilitaryBase | GovBuilding + | NKLaunchSite | NKMissileEvent | CoastGuardFacility | KoreanAirport + | NavWarning | PiracyZone | PowerFacility | HazardFacility + | CnFacility | JpFacility; + +export type StaticLayerKind = + | 'port' | 'windFarm' | 'militaryBase' | 'govBuilding' + | 'nkLaunch' | 'nkMissile' | 'coastGuard' | 'airport' + | 'navWarning' | 'piracy' | 'infra' | 'hazard' + | 'cnFacility' | 'jpFacility'; + +export interface StaticPickInfo { + kind: StaticLayerKind; + object: StaticPickedObject; +} + +export interface LayerFactoryConfig { + sc: number; // sizeScale + onPick: (info: StaticPickInfo) => void; +} + +export type { PickingInfo, Layer }; +export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent }; +export type { CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone }; +export type { PowerFacility, HazardFacility, HazardType, CnFacility, JpFacility }; + +export function hexToRgb(hex: string): [number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b]; +} diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 0d4c796..e18589c 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -1,75 +1,15 @@ import { useMemo } from 'react'; -import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers'; -import type { Layer, PickingInfo } from '@deck.gl/core'; -import { svgToDataUri } from '../utils/svgToDataUri'; - -// Data imports -import { EAST_ASIA_PORTS } from '../data/ports'; -import type { Port } from '../data/ports'; -import { KOREA_WIND_FARMS } from '../data/windFarms'; -import type { WindFarm } from '../data/windFarms'; -import { MILITARY_BASES } from '../data/militaryBases'; -import type { MilitaryBase } from '../data/militaryBases'; -import { GOV_BUILDINGS } from '../data/govBuildings'; -import type { GovBuilding } from '../data/govBuildings'; -import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../data/nkLaunchSites'; -import type { NKLaunchSite } from '../data/nkLaunchSites'; -import { NK_MISSILE_EVENTS } from '../data/nkMissileEvents'; -import type { NKMissileEvent } from '../data/nkMissileEvents'; -import { COAST_GUARD_FACILITIES } from '../services/coastGuard'; -import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard'; -import { KOREAN_AIRPORTS } from '../services/airports'; -import type { KoreanAirport } from '../services/airports'; -import { NAV_WARNINGS } from '../services/navWarning'; -import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning'; -import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy'; -import type { PiracyZone } from '../services/piracy'; +import type { Layer } from '@deck.gl/core'; import type { PowerFacility } from '../services/infra'; -import { HAZARD_FACILITIES } from '../data/hazardFacilities'; -import type { HazardFacility, HazardType } from '../data/hazardFacilities'; -import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../data/cnFacilities'; -import type { CnFacility } from '../data/cnFacilities'; -import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../data/jpFacilities'; -import type { JpFacility } from '../data/jpFacilities'; +import type { HazardType } from '../data/hazardFacilities'; -// ─── Type alias to avoid 'any' in PickingInfo ─────────────────────────────── +// Re-export types for consumers +export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types'; -export type StaticPickedObject = - | Port - | WindFarm - | MilitaryBase - | GovBuilding - | NKLaunchSite - | NKMissileEvent - | CoastGuardFacility - | KoreanAirport - | NavWarning - | PiracyZone - | PowerFacility - | HazardFacility - | CnFacility - | JpFacility; - -export type StaticLayerKind = - | 'port' - | 'windFarm' - | 'militaryBase' - | 'govBuilding' - | 'nkLaunch' - | 'nkMissile' - | 'coastGuard' - | 'airport' - | 'navWarning' - | 'piracy' - | 'infra' - | 'hazard' - | 'cnFacility' - | 'jpFacility'; - -export interface StaticPickInfo { - kind: StaticLayerKind; - object: StaticPickedObject; -} +import { createPortLayers } from './layers/createPortLayers'; +import { createNavigationLayers } from './layers/createNavigationLayers'; +import { createMilitaryLayers } from './layers/createMilitaryLayers'; +import { createFacilityLayers } from './layers/createFacilityLayers'; interface StaticLayerConfig { ports: boolean; @@ -89,973 +29,38 @@ interface StaticLayerConfig { cnMilitary: boolean; jpPower: boolean; jpMilitary: boolean; - onPick: (info: StaticPickInfo) => void; - sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0) + onPick: (info: import('./layers/types').StaticPickInfo) => void; + sizeScale?: number; } -// ─── Color helpers ──────────────────────────────────────────────────────────── - -function hexToRgb(hex: string): [number, number, number] { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return [r, g, b]; -} - -// ─── Port SVG ──────────────────────────────────────────────────────────────── - -const PORT_COUNTRY_COLOR: Record = { - KR: '#3b82f6', - CN: '#ef4444', - JP: '#f472b6', - KP: '#f97316', - TW: '#10b981', -}; - -function portSvg(color: string, size: number): string { - return ` - - - - - `; -} - -// ─── Wind Turbine SVG ───────────────────────────────────────────────────────── - -const WIND_COLOR = '#00bcd4'; - -function windTurbineSvg(size: number): string { - return ` - - - - - - - - `; -} - -// ─── CoastGuard SVG ─────────────────────────────────────────────────────────── - -const CG_TYPE_COLOR: Record = { - hq: '#ff6b6b', - regional: '#ffa94d', - station: '#4dabf7', - substation: '#69db7c', - vts: '#da77f2', - navy: '#3b82f6', -}; - -function coastGuardSvg(type: CoastGuardType, size: number): string { - const color = CG_TYPE_COLOR[type]; - if (type === 'navy') { - return ` - - - - - `; - } - if (type === 'vts') { - return ` - - - - - `; - } - return ` - - - - - - `; -} - -const CG_TYPE_SIZE: Record = { - hq: 24, - regional: 20, - station: 16, - substation: 13, - vts: 14, - navy: 18, -}; - -// ─── Airport SVG ───────────────────────────────────────────────────────────── - -const AP_COUNTRY_COLOR: Record = { - KR: { intl: '#a78bfa', domestic: '#7c8aaa' }, - CN: { intl: '#ef4444', domestic: '#b91c1c' }, - JP: { intl: '#f472b6', domestic: '#9d174d' }, - KP: { intl: '#f97316', domestic: '#c2410c' }, - TW: { intl: '#10b981', domestic: '#059669' }, -}; - -function apColor(ap: KoreanAirport): string { - const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR; - return ap.intl ? cc.intl : cc.domestic; -} - -function airportSvg(color: string, size: number): string { - return ` - - - `; -} - -// ─── NavWarning SVG ─────────────────────────────────────────────────────────── - -const NW_ORG_COLOR: Record = { - '해군': '#8b5cf6', - '해병대': '#22c55e', - '공군': '#f97316', - '육군': '#ef4444', - '해경': '#3b82f6', - '국과연': '#eab308', -}; - -function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string { - const color = NW_ORG_COLOR[org]; - if (level === 'danger') { - return ` - - - - `; - } - return ` - - - - `; -} - -// ─── Piracy SVG ─────────────────────────────────────────────────────────────── - -function piracySvg(color: string, size: number): string { - return ` - - - - - - - - `; -} - -// ─── NKMissile SVG ──────────────────────────────────────────────────────────── - -function getMissileColor(type: string): string { - if (type.includes('ICBM')) return '#dc2626'; - if (type.includes('IRBM')) return '#ef4444'; - if (type.includes('SLBM')) return '#3b82f6'; - return '#f97316'; -} - -function missileLaunchSvg(color: string): string { - return ` - - `; -} - -function missileImpactSvg(color: string): string { - return ` - - - - `; -} - -// ─── Infra SVG ──────────────────────────────────────────────────────────────── - -const INFRA_SOURCE_COLOR: Record = { - nuclear: '#e040fb', - coal: '#795548', - gas: '#ff9800', - oil: '#5d4037', - hydro: '#2196f3', - solar: '#ffc107', - wind: '#00bcd4', - biomass: '#4caf50', -}; -const INFRA_SUBSTATION_COLOR = '#ffeb3b'; - -function infraColor(f: PowerFacility): string { - if (f.type === 'substation') return INFRA_SUBSTATION_COLOR; - return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e'; -} - -function infraSvg(f: PowerFacility): string { - const color = infraColor(f); - if (f.source === 'wind') { - return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`); - } - const size = f.type === 'substation' ? 7 : 12; - return ` - - `; -} - -// ─── Memoized icon atlases ──────────────────────────────────────────────────── - -// We use individual Data URI per item via getIcon accessor instead of atlas -// ─── Main hook ─────────────────────────────────────────────────────────────── - export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { return useMemo(() => { - const layers: Layer[] = []; - const sc = config.sizeScale ?? 1.0; // 줌 레벨별 스케일 배율 + const fc = { sc: config.sizeScale ?? 1.0, onPick: config.onPick }; - // ── Ports ─────────────────────────────────────────────────────────────── - if (config.ports) { - // Build per-item data-uri icons: reuse by (country, type) key - const portIconCache = new Map(); - function getPortIconUrl(p: Port): string { - const key = `${p.country}-${p.type}`; - if (!portIconCache.has(key)) { - const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR; - const size = p.type === 'major' ? 32 : 24; - portIconCache.set(key, svgToDataUri(portSvg(color, size))); - } - return portIconCache.get(key)!; - } - - layers.push( - new IconLayer({ - id: 'static-ports-icon', - data: EAST_ASIA_PORTS, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ - url: getPortIconUrl(d), - width: d.type === 'major' ? 32 : 24, - height: d.type === 'major' ? 32 : 24, - anchorX: d.type === 'major' ? 16 : 12, - anchorY: d.type === 'major' ? 16 : 12, - }), - getSize: (d) => (d.type === 'major' ? 16 : 12) * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'port', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-ports-label', - data: EAST_ASIA_PORTS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo.replace('항', ''), - getSize: 9 * sc, - getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Wind Farms ───────────────────────────────────────────────────────── - if (config.windFarm) { - const windUrl = svgToDataUri(windTurbineSvg(36)); - layers.push( - new IconLayer({ - id: 'static-windfarm-icon', - data: KOREA_WIND_FARMS, - getPosition: (d) => [d.lng, d.lat], - getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }), - getSize: 18 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'windFarm', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-windfarm-label', - data: KOREA_WIND_FARMS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 9 * sc, - getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Coast Guard ──────────────────────────────────────────────────────── - if (config.coastGuard) { - const cgIconCache = new Map(); - function getCgIconUrl(type: CoastGuardType): string { - if (!cgIconCache.has(type)) { - const size = CG_TYPE_SIZE[type]; - cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2))); - } - return cgIconCache.get(type)!; - } - - layers.push( - new IconLayer({ - id: 'static-coastguard-icon', - data: COAST_GUARD_FACILITIES, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => { - const sz = CG_TYPE_SIZE[d.type] * 2; - return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; - }, - getSize: (d) => CG_TYPE_SIZE[d.type] * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'coastGuard', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-coastguard-label', - data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'), - getPosition: (d) => [d.lng, d.lat], - getText: (d) => { - if (d.type === 'vts') return 'VTS'; - if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); - return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; - }, - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Airports ─────────────────────────────────────────────────────────── - if (config.airports) { - const apIconCache = new Map(); - function getApIconUrl(ap: KoreanAirport): string { - const color = apColor(ap); - const size = ap.intl ? 40 : 32; - const key = `${color}-${size}`; - if (!apIconCache.has(key)) { - apIconCache.set(key, svgToDataUri(airportSvg(color, size))); - } - return apIconCache.get(key)!; - } - - layers.push( - new IconLayer({ - id: 'static-airports-icon', - data: KOREAN_AIRPORTS, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => { - const sz = d.intl ? 40 : 32; - return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; - }, - getSize: (d) => (d.intl ? 20 : 16) * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'airport', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-airports-label', - data: KOREAN_AIRPORTS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), - getSize: 9 * sc, - getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NavWarning ───────────────────────────────────────────────────────── - if (config.navWarning) { - const nwIconCache = new Map(); - function getNwIconUrl(w: NavWarning): string { - const key = `${w.level}-${w.org}`; - if (!nwIconCache.has(key)) { - const size = w.level === 'danger' ? 32 : 28; - nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size))); - } - return nwIconCache.get(key)!; - } - - layers.push( - new IconLayer({ - id: 'static-navwarning-icon', - data: NAV_WARNINGS, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => { - const sz = d.level === 'danger' ? 32 : 28; - return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; - }, - getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'navWarning', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-navwarning-label', - data: NAV_WARNINGS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.id, - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 9], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Piracy ───────────────────────────────────────────────────────────── - if (config.piracy) { - const piracyIconCache = new Map(); - function getPiracyIconUrl(zone: PiracyZone): string { - const key = zone.level; - if (!piracyIconCache.has(key)) { - const color = PIRACY_LEVEL_COLOR[zone.level]; - const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40; - piracyIconCache.set(key, svgToDataUri(piracySvg(color, size))); - } - return piracyIconCache.get(key)!; - } - - layers.push( - new IconLayer({ - id: 'static-piracy-icon', - data: PIRACY_ZONES, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => { - const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40; - return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; - }, - getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'piracy', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-piracy-label', - data: PIRACY_ZONES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo, - getSize: 9 * sc, - getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 14], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Military Bases — TextLayer (이모지) ─────────────────────────────── - if (config.militaryBases) { - const TYPE_COLOR: Record = { - naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', - missile: '#ef4444', joint: '#a78bfa', - }; - const TYPE_ICON: Record = { - naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', - }; - layers.push( - new TextLayer({ - id: 'static-militarybase-emoji', - data: MILITARY_BASES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => TYPE_ICON[d.type] ?? '⭐', - getSize: 14 * sc, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'militaryBase', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-militarybase-label', - data: MILITARY_BASES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 9], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── - if (config.govBuildings) { - const GOV_TYPE_COLOR: Record = { - executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', - intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', - }; - const GOV_TYPE_ICON: Record = { - executive: '🏛', legislature: '🏛', military_hq: '⭐', - intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', - }; - layers.push( - new TextLayer({ - id: 'static-govbuilding-emoji', - data: GOV_BUILDINGS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', - getSize: 12 * sc, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'govBuilding', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-govbuilding-label', - data: GOV_BUILDINGS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── - if (config.nkLaunch) { - layers.push( - new TextLayer({ - id: 'static-nklaunch-emoji', - data: NK_LAUNCH_SITES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀', - getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'nkLaunch', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-nklaunch-label', - data: NK_LAUNCH_SITES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NK Missile Events — IconLayer ───────────────────────────────────── - if (config.nkMissile) { - // Launch points (triangle) - const launchIconCache = new Map(); - function getLaunchIconUrl(type: string): string { - if (!launchIconCache.has(type)) { - launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); - } - return launchIconCache.get(type)!; - } - // Impact points (X) - const impactIconCache = new Map(); - function getImpactIconUrl(type: string): string { - if (!impactIconCache.has(type)) { - impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type)))); - } - return impactIconCache.get(type)!; - } - - interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number } - interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number } - - const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng })); - const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng })); - - // 발사→착탄 궤적선 - const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({ - path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][], - color: hexToRgb(getMissileColor(ev.type)), - })); - layers.push( - new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({ - id: 'static-nkmissile-trajectory', - data: trajectoryData, - getPath: (d) => d.path, - getColor: (d) => [...d.color, 150] as [number, number, number, number], - getWidth: 2, - widthUnits: 'pixels', - getDashArray: [6, 3], - dashJustified: true, - extensions: [], - }), - ); - - layers.push( - new IconLayer({ - id: 'static-nkmissile-launch', - data: launchData, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }), - getSize: 12 * sc, - getColor: (d) => { - const today = new Date().toISOString().slice(0, 10) === d.ev.date; - return [255, 255, 255, today ? 255 : 90] as [number, number, number, number]; - }, - }), - new IconLayer({ - id: 'static-nkmissile-impact', - data: impactData, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }), - getSize: 16 * sc, - getColor: (d) => { - const today = new Date().toISOString().slice(0, 10) === d.ev.date; - return [255, 255, 255, today ? 255 : 100] as [number, number, number, number]; - }, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'nkMissile', object: info.object.ev }); - return true; - }, - }), - new TextLayer({ - id: 'static-nkmissile-label', - data: impactData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Infra ────────────────────────────────────────────────────────────── - if (config.infra && config.infraFacilities.length > 0) { - const infraIconCache = new Map(); - function getInfraIconUrl(f: PowerFacility): string { - const key = `${f.type}-${f.source ?? ''}`; - if (!infraIconCache.has(key)) { - infraIconCache.set(key, svgToDataUri(infraSvg(f))); - } - return infraIconCache.get(key)!; - } - - const plants = config.infraFacilities.filter(f => f.type === 'plant'); - const substations = config.infraFacilities.filter(f => f.type === 'substation'); - - layers.push( - new IconLayer({ - id: 'static-infra-substation', - data: substations, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), - getSize: 7 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'infra', object: info.object }); - return true; - }, - }), - new IconLayer({ - id: 'static-infra-plant', - data: plants, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), - getSize: 12 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'infra', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-infra-plant-label', - data: plants, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Hazard Facilities ────────────────────────────────────────────────── - if (config.hazardTypes.length > 0) { - const hazardTypeSet = new Set(config.hazardTypes); - const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); - - const HAZARD_META: Record = { - petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, - lng: { icon: '🔵', color: [6, 182, 212, 255] }, - oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, - hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, - nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, - thermal: { icon: '🔥', color: [100, 116, 139, 255] }, - shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, - wastewater: { icon: '💧', color: [16, 185, 129, 255] }, - heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, - }; - - if (hazardData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-hazard-emoji', - data: hazardData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', - getSize: 16 * sc, - getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'hazard', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-hazard-label', - data: hazardData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 9 * sc, - getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - // ── CN Facilities ────────────────────────────────────────────────────── - { - const CN_META: Record = { - nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, - thermal: { icon: '🔥', color: [249, 115, 22, 255] }, - naval: { icon: '⚓', color: [59, 130, 246, 255] }, - airbase: { icon: '✈️', color: [34, 211, 238, 255] }, - army: { icon: '🪖', color: [34, 197, 94, 255] }, - shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, - }; - const cnData: CnFacility[] = [ - ...(config.cnPower ? CN_POWER_PLANTS : []), - ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), - ]; - if (cnData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-cn-emoji', - data: cnData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => CN_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, - getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'cnFacility', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-cn-label', - data: cnData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.name, - getSize: 9 * sc, - getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - // ── JP Facilities ────────────────────────────────────────────────────── - { - const JP_META: Record = { - nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, - thermal: { icon: '🔥', color: [249, 115, 22, 255] }, - naval: { icon: '⚓', color: [59, 130, 246, 255] }, - airbase: { icon: '✈️', color: [34, 211, 238, 255] }, - army: { icon: '🪖', color: [34, 197, 94, 255] }, - }; - const jpData: JpFacility[] = [ - ...(config.jpPower ? JP_POWER_PLANTS : []), - ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), - ]; - if (jpData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-jp-emoji', - data: jpData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => JP_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, - getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'jpFacility', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-jp-label', - data: jpData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.name, - getSize: 9 * sc, - getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - return layers; - // infraFacilities는 배열 참조가 바뀌어야 갱신 - // eslint-disable-next-line react-hooks/exhaustive-deps + return [ + ...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc), + ...createNavigationLayers({ + coastGuard: config.coastGuard, + airports: config.airports, + navWarning: config.navWarning, + piracy: config.piracy, + }, fc), + ...createMilitaryLayers({ + militaryBases: config.militaryBases, + govBuildings: config.govBuildings, + nkLaunch: config.nkLaunch, + nkMissile: config.nkMissile, + }, fc), + ...createFacilityLayers({ + infra: config.infra, + infraFacilities: config.infraFacilities, + hazardTypes: config.hazardTypes, + cnPower: config.cnPower, + cnMilitary: config.cnMilitary, + jpPower: config.jpPower, + jpMilitary: config.jpMilitary, + }, fc), + ]; }, [ config.ports, config.windFarm, @@ -1078,9 +83,3 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.sizeScale, ]); } - -// Re-export types that KoreaMap will need for Popup rendering -export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility }; -export type { HazardFacility, HazardType, CnFacility, JpFacility }; -// Re-export label/color helpers used in Popup rendering -export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor }; -- 2.45.2 From aff17588b2035dc9cee68a59058757053ca8655c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:43:06 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20KoreaDashboard=20named=20export=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(default=E2=86=92named)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/korea/KoreaDashboard.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 3482521..7d4c14a 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -67,7 +67,7 @@ export interface KoreaDashboardProps { onTimeZoneChange: (tz: 'KST' | 'UTC') => void; } -const KoreaDashboard = ({ +export const KoreaDashboard = ({ currentTime, isLive, refreshKey, @@ -368,4 +368,3 @@ const KoreaDashboard = ({ ); }; -export default KoreaDashboard; -- 2.45.2 From 728936439b18c1fa388ef94145246ce4b9135d1d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:46:24 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20Phase=203=20=E2=80=94=20Stati?= =?UTF-8?q?cFacilityPopup=20=EC=B6=94=EC=B6=9C=20(KoreaMap=20935=EC=A4=84?= =?UTF-8?q?=E2=86=92742=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaticFacilityPopup: 시설 클릭 Popup 로직 독립 컴포넌트 (207줄) - KoreaMap: IIFE Popup 블록(~200줄) → 컴포넌트 호출 3줄로 대체 - SUB_META/KIND_DEFAULT 상수 + 배지/필드 렌더링 모두 이동 --- frontend/src/components/korea/KoreaMap.tsx | 203 +---------------- .../components/korea/StaticFacilityPopup.tsx | 207 ++++++++++++++++++ 2 files changed, 212 insertions(+), 198 deletions(-) create mode 100644 frontend/src/components/korea/StaticFacilityPopup.tsx diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2c40b73..3701952 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Map, NavigationControl, Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; @@ -20,6 +20,7 @@ import { EezLayer } from './EezLayer'; // PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer, // NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨 import { ChineseFishingOverlay } from './ChineseFishingOverlay'; +import { StaticFacilityPopup } from './StaticFacilityPopup'; // HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨 import { AnalysisOverlay } from './AnalysisOverlay'; import { FleetClusterLayer } from './FleetClusterLayer'; @@ -641,203 +642,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ...analysisDeckLayers, ].filter(Boolean)} /> {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} - {staticPickInfo && (() => { - const obj = staticPickInfo.object; - const kind = staticPickInfo.kind; - const lat = obj.lat ?? obj.launchLat ?? 0; - const lng = obj.lng ?? obj.launchLng ?? 0; - if (!lat || !lng) return null; - - // ── kind + subType 기반 메타 결정 ── - const SUB_META: Record> = { - hazard: { - petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' }, - lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' }, - oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' }, - hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' }, - nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' }, - thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' }, - shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' }, - wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' }, - heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' }, - }, - overseas: { - nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' }, - thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' }, - naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, - airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' }, - army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, - shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' }, - }, - militaryBase: { - naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, - airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' }, - army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, - missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' }, - joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' }, - }, - govBuilding: { - executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' }, - legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' }, - military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' }, - intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' }, - foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' }, - maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' }, - defense: { icon: '🛡️', color: '#dc2626', label: '국방부' }, - }, - nkLaunch: { - icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' }, - irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' }, - srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' }, - slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' }, - cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' }, - artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' }, - mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' }, - }, - coastGuard: { - hq: { icon: '🏢', color: '#3b82f6', label: '본청' }, - regional: { icon: '🏢', color: '#60a5fa', label: '지방청' }, - station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' }, - substation: { icon: '🏠', color: '#94a3b8', label: '파출소' }, - vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' }, - navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' }, - }, - airport: { - international: { icon: '✈️', color: '#a78bfa', label: '국제공항' }, - domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' }, - military: { icon: '✈️', color: '#ef4444', label: '군용비행장' }, - }, - navWarning: { - danger: { icon: '⚠️', color: '#ef4444', label: '위험' }, - caution: { icon: '⚠️', color: '#eab308', label: '주의' }, - info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' }, - }, - piracy: { - critical: { icon: '☠️', color: '#ef4444', label: '극고위험' }, - high: { icon: '☠️', color: '#f97316', label: '고위험' }, - moderate: { icon: '☠️', color: '#eab308', label: '주의' }, - }, - }; - - const KIND_DEFAULT: Record = { - port: { icon: '⚓', color: '#3b82f6', label: '항구' }, - windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' }, - militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' }, - govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' }, - nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' }, - nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' }, - coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' }, - airport: { icon: '✈️', color: '#a78bfa', label: '공항' }, - navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' }, - piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' }, - infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' }, - hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' }, - cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' }, - jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' }, - }; - - // subType 키 결정 - const subKey = obj.type ?? obj.subType ?? obj.level ?? ''; - const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind; - const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind }; - - // 국가 플래그 - const COUNTRY_FLAG: Record = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' }; - const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? ''; - const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본' - : { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? ''; - - // 이름 결정 - const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind; - - return ( - setStaticPickInfo(null)} closeOnClick={false} - maxWidth="280px" className="gl-popup" - > -
- {/* 컬러 헤더 */} -
- {meta.icon} {title} -
- {/* 배지 행 */} -
- - {meta.label} - - {flag && ( - - {flag} {countryName} - - )} - {kind === 'hazard' && ( - ⚠️ 위험시설 - )} - {kind === 'port' && ( - - {obj.type === 'major' ? '주요항' : '중소항'} - - )} - {kind === 'airport' && obj.intl && ( - 국제선 - )} -
- {/* 설명 */} - {obj.description && ( -
{obj.description}
- )} - {obj.detail && ( -
{obj.detail}
- )} - {obj.note && ( -
{obj.note}
- )} - {/* 필드 그리드 */} -
- {obj.operator &&
운영: {obj.operator}
} - {obj.capacity &&
규모: {obj.capacity}
} - {obj.output &&
출력: {obj.output}
} - {obj.source &&
연료: {obj.source}
} - {obj.capacityMW &&
용량: {obj.capacityMW}MW
} - {obj.turbines &&
터빈: {obj.turbines}기
} - {obj.status &&
상태: {obj.status}
} - {obj.year &&
연도: {obj.year}년
} - {obj.region &&
지역: {obj.region}
} - {obj.org &&
기관: {obj.org}
} - {obj.area &&
해역: {obj.area}
} - {obj.altitude &&
고도: {obj.altitude}
} - {obj.address &&
주소: {obj.address}
} - {obj.recentUse &&
최근 사용: {obj.recentUse}
} - {obj.recentIncidents != null &&
최근 1년: {obj.recentIncidents}건
} - {obj.icao &&
ICAO: {obj.icao}
} - {kind === 'nkMissile' && ( - <> - {obj.typeKo &&
미사일: {obj.typeKo}
} - {obj.date &&
발사일: {obj.date} {obj.time}
} - {obj.distanceKm &&
사거리: {obj.distanceKm}km
} - {obj.altitudeKm &&
최고고도: {obj.altitudeKm}km
} - {obj.flightMin &&
비행시간: {obj.flightMin}분
} - {obj.launchNameKo &&
발사지: {obj.launchNameKo}
} - - )} - {obj.name && obj.nameKo && obj.name !== obj.nameKo && ( -
영문: {obj.name}
- )} -
- {lat.toFixed(4)}°N, {lng.toFixed(4)}°E -
-
-
-
- ); - })()} + {staticPickInfo && ( + setStaticPickInfo(null)} /> + )} {layers.osint && } {layers.eez && } diff --git a/frontend/src/components/korea/StaticFacilityPopup.tsx b/frontend/src/components/korea/StaticFacilityPopup.tsx new file mode 100644 index 0000000..9d0f8ba --- /dev/null +++ b/frontend/src/components/korea/StaticFacilityPopup.tsx @@ -0,0 +1,207 @@ +import { Popup } from 'react-map-gl/maplibre'; +import type { StaticPickInfo } from '../../hooks/layers/types'; + +interface StaticFacilityPopupProps { + pickInfo: StaticPickInfo; + onClose: () => void; +} + +const StaticFacilityPopup = ({ pickInfo, onClose }: StaticFacilityPopupProps) => { + const obj = pickInfo.object as any; // eslint-disable-line @typescript-eslint/no-explicit-any -- StaticPickedObject union requires loose access + const kind = pickInfo.kind; + const lat = obj.lat ?? obj.launchLat ?? 0; + const lng = obj.lng ?? obj.launchLng ?? 0; + if (!lat || !lng) return null; + + // ── kind + subType 기반 메타 결정 ── + const SUB_META: Record> = { + hazard: { + petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' }, + lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' }, + oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' }, + hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' }, + nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' }, + thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' }, + shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' }, + wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' }, + heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' }, + }, + overseas: { + nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' }, + thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' }, + naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, + airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' }, + army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, + shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' }, + }, + militaryBase: { + naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, + airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' }, + army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, + missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' }, + joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' }, + }, + govBuilding: { + executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' }, + legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' }, + military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' }, + intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' }, + foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' }, + maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' }, + defense: { icon: '🛡️', color: '#dc2626', label: '국방부' }, + }, + nkLaunch: { + icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' }, + irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' }, + srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' }, + slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' }, + cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' }, + artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' }, + mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' }, + }, + coastGuard: { + hq: { icon: '🏢', color: '#3b82f6', label: '본청' }, + regional: { icon: '🏢', color: '#60a5fa', label: '지방청' }, + station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' }, + substation: { icon: '🏠', color: '#94a3b8', label: '파출소' }, + vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' }, + navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' }, + }, + airport: { + international: { icon: '✈️', color: '#a78bfa', label: '국제공항' }, + domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' }, + military: { icon: '✈️', color: '#ef4444', label: '군용비행장' }, + }, + navWarning: { + danger: { icon: '⚠️', color: '#ef4444', label: '위험' }, + caution: { icon: '⚠️', color: '#eab308', label: '주의' }, + info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' }, + }, + piracy: { + critical: { icon: '☠️', color: '#ef4444', label: '극고위험' }, + high: { icon: '☠️', color: '#f97316', label: '고위험' }, + moderate: { icon: '☠️', color: '#eab308', label: '주의' }, + }, + }; + + const KIND_DEFAULT: Record = { + port: { icon: '⚓', color: '#3b82f6', label: '항구' }, + windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' }, + militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' }, + govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' }, + nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' }, + nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' }, + coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' }, + airport: { icon: '✈️', color: '#a78bfa', label: '공항' }, + navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' }, + piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' }, + infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' }, + hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' }, + cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' }, + jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' }, + }; + + // subType 키 결정 + const subKey = obj.type ?? obj.subType ?? obj.level ?? ''; + const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind; + const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind }; + + // 국가 플래그 + const COUNTRY_FLAG: Record = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' }; + const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? ''; + const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본' + : { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? ''; + + // 이름 결정 + const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind; + + return ( + +
+ {/* 컬러 헤더 */} +
+ {meta.icon} {title} +
+ {/* 배지 행 */} +
+ + {meta.label} + + {flag && ( + + {flag} {countryName} + + )} + {kind === 'hazard' && ( + ⚠️ 위험시설 + )} + {kind === 'port' && ( + + {obj.type === 'major' ? '주요항' : '중소항'} + + )} + {kind === 'airport' && obj.intl && ( + 국제선 + )} +
+ {/* 설명 */} + {obj.description && ( +
{obj.description}
+ )} + {obj.detail && ( +
{obj.detail}
+ )} + {obj.note && ( +
{obj.note}
+ )} + {/* 필드 그리드 */} +
+ {obj.operator &&
운영: {obj.operator}
} + {obj.capacity &&
규모: {obj.capacity}
} + {obj.output &&
출력: {obj.output}
} + {obj.source &&
연료: {obj.source}
} + {obj.capacityMW &&
용량: {obj.capacityMW}MW
} + {obj.turbines &&
터빈: {obj.turbines}기
} + {obj.status &&
상태: {obj.status}
} + {obj.year &&
연도: {obj.year}년
} + {obj.region &&
지역: {obj.region}
} + {obj.org &&
기관: {obj.org}
} + {obj.area &&
해역: {obj.area}
} + {obj.altitude &&
고도: {obj.altitude}
} + {obj.address &&
주소: {obj.address}
} + {obj.recentUse &&
최근 사용: {obj.recentUse}
} + {obj.recentIncidents != null &&
최근 1년: {obj.recentIncidents}건
} + {obj.icao &&
ICAO: {obj.icao}
} + {kind === 'nkMissile' && ( + <> + {obj.typeKo &&
미사일: {obj.typeKo}
} + {obj.date &&
발사일: {obj.date} {obj.time}
} + {obj.distanceKm &&
사거리: {obj.distanceKm}km
} + {obj.altitudeKm &&
최고고도: {obj.altitudeKm}km
} + {obj.flightMin &&
비행시간: {obj.flightMin}분
} + {obj.launchNameKo &&
발사지: {obj.launchNameKo}
} + + )} + {obj.name && obj.nameKo && obj.name !== obj.nameKo && ( +
영문: {obj.name}
+ )} +
+ {lat.toFixed(4)}°N, {lng.toFixed(4)}°E +
+
+
+
+ ); +}; + +export { StaticFacilityPopup }; -- 2.45.2 From c6c3b5ffb975dc8d1210fefcdfe004fc1f38ac20 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:47:48 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20Phase=204-1=20=E2=80=94=20geo?= =?UTF-8?q?metry=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EC=B6=9C=20(FleetCluster?= =?UTF-8?q?Layer=20979=EC=A4=84=E2=86=92927=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/geometry.ts: convexHull, padPolygon, clusterColor 추출 (48줄) - FleetClusterLayer: 로컬 함수 → import로 교체 --- .../components/korea/FleetClusterLayer.tsx | 54 +------------------ frontend/src/utils/geometry.ts | 50 +++++++++++++++++ 2 files changed, 51 insertions(+), 53 deletions(-) create mode 100644 frontend/src/utils/geometry.ts diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 7c61c13..a53e28a 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -5,6 +5,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; +import { convexHull, padPolygon, clusterColor } from '../../utils/geometry'; export interface SelectedGearGroupData { parent: Ship | null; @@ -28,59 +29,6 @@ interface Props { onSelectedFleetChange?: (data: SelectedFleetData | null) => void; } -// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 -function cross(o: [number, number], a: [number, number], b: [number, number]): number { - return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); -} - -// Graham scan 기반 볼록 껍질 (반시계 방향) -function convexHull(points: [number, number][]): [number, number][] { - const n = points.length; - if (n < 2) return points.slice(); - const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); - const lower: [number, number][] = []; - for (const p of sorted) { - while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) { - lower.pop(); - } - lower.push(p); - } - const upper: [number, number][] = []; - for (let i = sorted.length - 1; i >= 0; i--) { - const p = sorted[i]; - while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) { - upper.pop(); - } - upper.push(p); - } - // lower + upper (첫/끝 중복 제거) - lower.pop(); - upper.pop(); - return lower.concat(upper); -} - -// 중심에서 각 꼭짓점 방향으로 padding 확장 -function padPolygon(hull: [number, number][], padding: number): [number, number][] { - if (hull.length === 0) return hull; - const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length; - const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length; - return hull.map(([x, y]) => { - const dx = x - cx; - const dy = y - cy; - const len = Math.sqrt(dx * dx + dy * dy); - if (len === 0) return [x + padding, y + padding] as [number, number]; - const scale = (len + padding) / len; - return [cx + dx * scale, cy + dy * scale] as [number, number]; - }); -} - -// cluster_id 해시 → HSL 색상 -function clusterColor(id: number): string { - const h = (id * 137) % 360; - return `hsl(${h}, 80%, 55%)`; -} - -// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능) // GeoJSON feature에 color 속성으로 주입 interface ClusterPolygonFeature { type: 'Feature'; diff --git a/frontend/src/utils/geometry.ts b/frontend/src/utils/geometry.ts new file mode 100644 index 0000000..4ebceff --- /dev/null +++ b/frontend/src/utils/geometry.ts @@ -0,0 +1,50 @@ +/** 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 */ +function cross(o: [number, number], a: [number, number], b: [number, number]): number { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +} + +/** Graham scan 기반 볼록 껍질 (반시계 방향) */ +export function convexHull(points: [number, number][]): [number, number][] { + const n = points.length; + if (n < 2) return points.slice(); + const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const lower: [number, number][] = []; + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) { + lower.pop(); + } + lower.push(p); + } + const upper: [number, number][] = []; + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) { + upper.pop(); + } + upper.push(p); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +/** 중심에서 각 꼭짓점 방향으로 padding 확장 */ +export function padPolygon(hull: [number, number][], padding: number): [number, number][] { + if (hull.length === 0) return hull; + const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length; + const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length; + return hull.map(([x, y]) => { + const dx = x - cx; + const dy = y - cy; + const len = Math.sqrt(dx * dx + dy * dy); + if (len === 0) return [x + padding, y + padding] as [number, number]; + const scale = (len + padding) / len; + return [cx + dx * scale, cy + dy * scale] as [number, number]; + }); +} + +/** cluster_id 해시 → HSL 색상 */ +export function clusterColor(id: number): string { + const h = (id * 137) % 360; + return `hsl(${h}, 80%, 55%)`; +} -- 2.45.2 From 2b009ca81a50c22b5c52e6e756e37f7a4717323f Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:56:47 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20Phase=204-2=20=E2=80=94=20shi?= =?UTF-8?q?pClassification=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EC=B6=9C=20(Sh?= =?UTF-8?q?ipLayer=20862=EC=A4=84=E2=86=92769=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/shipClassification.ts: MT 색상/타입매핑/국기/사이즈 추출 (87줄) - ShipLayer: 로컬 상수/함수 93줄 → import 1줄로 교체 --- frontend/src/components/layers/ShipLayer.tsx | 97 +------------------- frontend/src/utils/shipClassification.ts | 84 +++++++++++++++++ 2 files changed, 86 insertions(+), 95 deletions(-) create mode 100644 frontend/src/utils/shipClassification.ts diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index dd40192..afe2ad0 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -1,8 +1,9 @@ import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; -import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types'; +import type { Ship, VesselAnalysisDto } from '../../types'; import maplibregl from 'maplibre-gl'; +import { MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification'; interface Props { ships: Ship[]; @@ -14,100 +15,6 @@ interface Props { analysisMap?: Map; } -// ── MarineTraffic-style vessel type colors (CSS variable references) ── -const MT_TYPE_COLORS: Record = { - cargo: 'var(--kcg-ship-cargo)', - tanker: 'var(--kcg-ship-tanker)', - passenger: 'var(--kcg-ship-passenger)', - fishing: 'var(--kcg-ship-fishing)', - fishing_gear: '#f97316', - pleasure: 'var(--kcg-ship-pleasure)', - military: 'var(--kcg-ship-military)', - tug_special: 'var(--kcg-ship-tug)', - other: 'var(--kcg-ship-other)', - unknown: 'var(--kcg-ship-unknown)', -}; - -// Resolved hex colors for MapLibre paint (which cannot use CSS vars) -const MT_TYPE_HEX: Record = { - cargo: '#f0a830', - tanker: '#e74c3c', - passenger: '#4caf50', - fishing: '#42a5f5', - fishing_gear: '#f97316', - pleasure: '#e91e8c', - military: '#d32f2f', - tug_special: '#2e7d32', - other: '#5c6bc0', - unknown: '#9e9e9e', -}; - -// Map our internal ShipCategory + typecode → MT visual type -function getMTType(ship: Ship): string { - const tc = (ship.typecode || '').toUpperCase(); - const cat = ship.category; - - // Military first - if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military'; - if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military'; - - // Tanker - if (cat === 'tanker') return 'tanker'; - if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker'; - if (tc.startsWith('A1')) return 'tanker'; - - // Cargo - if (cat === 'cargo') return 'cargo'; - if (tc === 'CONT' || tc === 'BULK') return 'cargo'; - if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo'; - - // Passenger - if (tc === 'PASS' || tc.startsWith('B')) return 'passenger'; - - // Fishing - if (tc.startsWith('C')) return 'fishing'; - - // Tug / Special - if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special'; - - // Pleasure - if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure'; - - if (cat === 'civilian') return 'other'; - return 'unknown'; -} - -// Legacy navy flag colors (for popup header accent only) -const NAVY_COLORS: Record = { - US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff', - IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43', -}; - -const FLAG_EMOJI: Record = { - US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', - KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', - AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}', - CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', - MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}', - BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}', - SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}', -}; - -// icon-size multiplier (symbol layer, base=64px) -const SIZE_MAP: Record = { - carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16, - tanker: 0.16, cargo: 0.16, fishing: 0.14, civilian: 0.14, unknown: 0.12, -}; - -const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol']; - -function isMilitary(category: ShipCategory): boolean { - return MIL_CATEGORIES.includes(category); -} - -function getShipColor(ship: Ship): string { - return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown; -} function getShipHex(ship: Ship): string { return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown; diff --git a/frontend/src/utils/shipClassification.ts b/frontend/src/utils/shipClassification.ts new file mode 100644 index 0000000..f154020 --- /dev/null +++ b/frontend/src/utils/shipClassification.ts @@ -0,0 +1,84 @@ +import type { Ship, ShipCategory } from '../types'; + +// ── MarineTraffic-style vessel type colors (CSS variable references) ── +export const MT_TYPE_COLORS: Record = { + cargo: 'var(--kcg-ship-cargo)', + tanker: 'var(--kcg-ship-tanker)', + passenger: 'var(--kcg-ship-passenger)', + fishing: 'var(--kcg-ship-fishing)', + fishing_gear: '#f97316', + pleasure: 'var(--kcg-ship-pleasure)', + military: 'var(--kcg-ship-military)', + tug_special: 'var(--kcg-ship-tug)', + other: 'var(--kcg-ship-other)', + unknown: 'var(--kcg-ship-unknown)', +}; + +// Resolved hex colors for MapLibre paint (which cannot use CSS vars) +export const MT_TYPE_HEX: Record = { + cargo: '#f0a830', + tanker: '#e74c3c', + passenger: '#4caf50', + fishing: '#42a5f5', + fishing_gear: '#f97316', + pleasure: '#e91e8c', + military: '#d32f2f', + tug_special: '#2e7d32', + other: '#5c6bc0', + unknown: '#9e9e9e', +}; + +export function getMTType(ship: Ship): string { + const tc = (ship.typecode || '').toUpperCase(); + const cat = ship.category; + + if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military'; + if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military'; + + if (cat === 'tanker') return 'tanker'; + if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker'; + if (tc.startsWith('A1')) return 'tanker'; + + if (cat === 'cargo') return 'cargo'; + if (tc === 'CONT' || tc === 'BULK') return 'cargo'; + if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo'; + + if (tc === 'PASS' || tc.startsWith('B')) return 'passenger'; + if (tc.startsWith('C')) return 'fishing'; + if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special'; + if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure'; + + if (cat === 'civilian') return 'other'; + return 'unknown'; +} + +// Legacy navy flag colors (for popup header accent only) +export const NAVY_COLORS: Record = { + US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff', + IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43', +}; + +export const FLAG_EMOJI: Record = { + US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', + KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', + AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}', + CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', + MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}', + BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}', + SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}', +}; + +export const SIZE_MAP: Record = { + carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16, + tanker: 0.16, cargo: 0.16, fishing: 0.14, civilian: 0.14, unknown: 0.12, +}; + +export const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol']; + +export function isMilitary(category: ShipCategory): boolean { + return MIL_CATEGORIES.includes(category); +} + +export function getShipColor(ship: Ship): string { + return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown; +} -- 2.45.2 From 03f659986f2504edc590a0b0d60f289ca1e66e89 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:58:00 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20Phase=205+6=20=E2=80=94=20?= =?UTF-8?q?=EC=A4=8C=20=EB=94=94=EB=B0=94=EC=9A=B4=EC=8B=B1=20+=20API=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20+=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EC=9C=A0=ED=8B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 (렌더링 최적화): - KoreaMap: onZoom ref 기반 비교로 불필요한 setState 방지 Phase 6 (서비스 정리): - apiClient.ts: kcgFetch/externalFetch 래퍼 (credentials, error handling) - usePoll.ts: 공통 폴링 훅 (interval + enabled + graceful error) --- frontend/src/components/korea/KoreaMap.tsx | 10 ++++++- frontend/src/hooks/usePoll.ts | 34 ++++++++++++++++++++++ frontend/src/services/apiClient.ts | 30 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks/usePoll.ts create mode 100644 frontend/src/services/apiClient.ts diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 3701952..acee348 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -142,6 +142,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [selectedGearData, setSelectedGearData] = useState(null); const [selectedFleetData, setSelectedFleetData] = useState(null); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); + const zoomRef = useRef(KOREA_MAP_ZOOM); + const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { + const z = Math.floor(e.viewState.zoom); + if (z !== zoomRef.current) { + zoomRef.current = z; + setZoomLevel(z); + } + }, []); const [staticPickInfo, setStaticPickInfo] = useState(null); useEffect(() => { @@ -480,7 +488,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }} style={{ width: '100%', height: '100%' }} mapStyle={MAP_STYLE} - onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))} + onZoom={handleZoom} > diff --git a/frontend/src/hooks/usePoll.ts b/frontend/src/hooks/usePoll.ts new file mode 100644 index 0000000..257e497 --- /dev/null +++ b/frontend/src/hooks/usePoll.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useCallback } from 'react'; + +/** + * 공통 폴링 훅 — 주기적으로 fetchFn을 호출하고 결과를 onData로 전달. + * enabled가 false면 폴링 중지. + */ +export function usePoll( + fetchFn: () => Promise, + onData: (data: T) => void, + intervalMs: number, + enabled = true, +): void { + const onDataRef = useRef(onData); + onDataRef.current = onData; + + const fetchRef = useRef(fetchFn); + fetchRef.current = fetchFn; + + const doFetch = useCallback(async () => { + try { + const data = await fetchRef.current(); + onDataRef.current(data); + } catch { + // graceful — 기존 데이터 유지 + } + }, []); + + useEffect(() => { + if (!enabled) return; + doFetch(); + const t = setInterval(doFetch, intervalMs); + return () => clearInterval(t); + }, [enabled, intervalMs, doFetch]); +} diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 0000000..abcbd2b --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,30 @@ +const BASE_PREFIX = '/api/kcg'; + +/** + * KCG 백엔드 API 호출 래퍼. + * - 자동 credentials: 'include' + * - JSON 파싱 + * - 에러 시 null 반환 (graceful degradation) + */ +export async function kcgFetch(path: string): Promise { + try { + const res = await fetch(`${BASE_PREFIX}${path}`, { credentials: 'include' }); + if (!res.ok) return null; + return await res.json() as T; + } catch { + return null; + } +} + +/** + * 외부 API 호출 래퍼 (CORS 프록시 경유). + */ +export async function externalFetch(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + return await res.json() as T; + } catch { + return null; + } +} -- 2.45.2 From 5fd1e2b3cf0976d2a8b9b2b15cd19c5c5cb1f668 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 11:05:05 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20cn-fishing/localStorage=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=ED=86=B5=ED=95=A9=20=E2=80=94=20develop?= =?UTF-8?q?=20=EB=A8=B8=EC=A7=80=20=ED=9B=84=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KoreaDashboard: useLocalStorage 적용 (koreaLayers, nationalities) - KoreaDashboard: useKoreaFilters에 cnFishingOn 파라미터 전달 - KoreaDashboard: useCallback 의존성 React Compiler 호환 - FleetClusterLayer: geometry import 복원 + 로컬 함수 제거 --- .../components/korea/FleetClusterLayer.tsx | 54 +------------------ .../src/components/korea/KoreaDashboard.tsx | 14 ++--- 2 files changed, 9 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 76f3b10..f73cf35 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -6,6 +6,7 @@ import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; import { classifyFishingZone } from '../../utils/fishingAnalysis'; +import { convexHull, padPolygon, clusterColor } from '../../utils/geometry'; export interface SelectedGearGroupData { parent: Ship | null; @@ -29,59 +30,6 @@ interface Props { onSelectedFleetChange?: (data: SelectedFleetData | null) => void; } -// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 -function cross(o: [number, number], a: [number, number], b: [number, number]): number { - return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); -} - -// Graham scan 기반 볼록 껍질 (반시계 방향) -function convexHull(points: [number, number][]): [number, number][] { - const n = points.length; - if (n < 2) return points.slice(); - const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); - const lower: [number, number][] = []; - for (const p of sorted) { - while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) { - lower.pop(); - } - lower.push(p); - } - const upper: [number, number][] = []; - for (let i = sorted.length - 1; i >= 0; i--) { - const p = sorted[i]; - while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) { - upper.pop(); - } - upper.push(p); - } - // lower + upper (첫/끝 중복 제거) - lower.pop(); - upper.pop(); - return lower.concat(upper); -} - -// 중심에서 각 꼭짓점 방향으로 padding 확장 -function padPolygon(hull: [number, number][], padding: number): [number, number][] { - if (hull.length === 0) return hull; - const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length; - const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length; - return hull.map(([x, y]) => { - const dx = x - cx; - const dy = y - cy; - const len = Math.sqrt(dx * dx + dy * dy); - if (len === 0) return [x + padding, y + padding] as [number, number]; - const scale = (len + padding) / len; - return [cx + dx * scale, cy + dy * scale] as [number, number]; - }); -} - -// cluster_id 해시 → HSL 색상 -function clusterColor(id: number): string { - const h = (id * 137) % 360; - return `hsl(${h}, 80%, 55%)`; -} - -// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능) // GeoJSON feature에 color 속성으로 주입 interface ClusterPolygonFeature { type: 'Feature'; diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 7d4c14a..d0a8e28 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage'; import { KoreaMap } from './KoreaMap'; import { FieldAnalysisModal } from './FieldAnalysisModal'; import { LayerPanel } from '../common/LayerPanel'; @@ -82,7 +83,7 @@ export const KoreaDashboard = ({ const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = useSharedFilters(); - const [koreaLayers, setKoreaLayers] = useState>({ + const [koreaLayers, setKoreaLayers] = useLocalStorage>('koreaLayers', { ships: true, aircraft: true, satellites: true, @@ -122,25 +123,25 @@ export const KoreaDashboard = ({ const toggleKoreaLayer = useCallback((key: string) => { setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); + }, [setKoreaLayers]); - const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); + const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set()); const toggleNationality = useCallback((nat: string) => { setHiddenNationalities(prev => { const next = new Set(prev); if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } return next; }); - }, []); + }, [setHiddenNationalities]); - const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); + const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set()); const toggleFishingNat = useCallback((nat: string) => { setHiddenFishingNats(prev => { const next = new Set(prev); if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } return next; }); - }, []); + }, [setHiddenFishingNats]); const koreaData = useKoreaData({ currentTime, @@ -158,6 +159,7 @@ export const KoreaDashboard = ({ koreaData.visibleShips, currentTime, vesselAnalysis.analysisMap, + koreaLayers.cnFishing, ); const handleTabChange = useCallback((_tab: DashboardTab) => { -- 2.45.2