diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d97bd9e..7e53296 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useReplay } from './hooks/useReplay'; import { useMonitor } from './hooks/useMonitor'; +import { useLocalStorage } from './hooks/useLocalStorage'; import type { AppMode } from './types'; import { useTheme } from './hooks/useTheme'; import { useAuth } from './hooks/useAuth'; @@ -40,7 +41,7 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); + const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran'); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index e604861..f538203 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -100,6 +100,19 @@ const NAT_COLORS: Record = { unclassified: '#6b7280', }; +// ── New tree interface ──────────────────────────────── + +export interface LayerTreeNode { + key: string; + label: string; + color: string; + count?: number; + children?: LayerTreeNode[]; + specialRenderer?: 'shipCategories' | 'aircraftCategories' | 'nationalityCategories'; +} + +// ── Legacy interfaces (kept for backward compat) ───── + interface ExtraLayer { key: string; label: string; @@ -141,14 +154,39 @@ function countOverseasActiveLeaves(items: OverseasItem[], layers: Record): boolean { + if (!node.children) return !!layers[node.key]; + return node.children.some(c => isNodeActive(c, layers)); +} + +function getTreeCount(node: LayerTreeNode, layers: Record): number { + if (!node.children) return node.count ?? 0; + return node.children.reduce((sum, c) => { + if (!c.children && !layers[c.key]) return sum; + if (c.children && !isNodeActive(c, layers)) return sum; + return sum + getTreeCount(c, layers); + }, 0); +} + +// ── Props ───────────────────────────────────────────── + interface LayerPanelProps { layers: Record; onToggle: (key: string) => void; + onBatchToggle?: (keys: string[], value: boolean) => void; aircraftByCategory: Record; aircraftTotal: number; shipsByMtCategory: Record; shipTotal: number; satelliteCount: number; + tree?: LayerTreeNode[]; extraLayers?: ExtraLayer[]; overseasItems?: OverseasItem[]; hiddenAcCategories: Set; @@ -163,14 +201,391 @@ interface LayerPanelProps { onFishingNatToggle?: (nat: string) => void; } +// ── Special renderer props (shared across recursive calls) ── + +interface SpecialRendererProps { + shipsByMtCategory: Record; + hiddenShipCategories: Set; + onShipCategoryToggle: (cat: string) => void; + aircraftByCategory: Record; + hiddenAcCategories: Set; + onAcCategoryToggle: (cat: string) => void; + fishingByNationality?: Record; + hiddenFishingNats?: Set; + onFishingNatToggle?: (nat: string) => void; + shipsByNationality?: Record; + hiddenNationalities?: Set; + onNationalityToggle?: (nat: string) => void; + legendOpen: Set; + toggleLegend: (key: string) => void; + expanded: Set; + toggleExpand: (key: string) => void; +} + +// ── Ship categories special renderer ───────────────── + +function ShipCategoriesContent({ + shipsByMtCategory, + hiddenShipCategories, + onShipCategoryToggle, + fishingByNationality, + hiddenFishingNats, + onFishingNatToggle, + shipsByNationality, + legendOpen, + toggleLegend, + expanded, + toggleExpand, +}: { + shipsByMtCategory: Record; + hiddenShipCategories: Set; + onShipCategoryToggle: (cat: string) => void; + fishingByNationality?: Record; + hiddenFishingNats?: Set; + onFishingNatToggle?: (nat: string) => void; + shipsByNationality?: Record; + legendOpen: Set; + toggleLegend: (key: string) => void; + expanded: Set; + toggleExpand: (key: string) => void; +}) { + const { t } = useTranslation(['common', 'ships']); + return ( +
+ {MT_CATEGORIES.map(cat => { + const count = shipsByMtCategory[cat] || 0; + if (count === 0) return null; + + if (cat === 'fishing' && fishingByNationality && hiddenFishingNats && onFishingNatToggle) { + const isFishingExpanded = expanded.has('fishing-sub'); + return ( +
+
+ { e.stopPropagation(); toggleExpand('fishing-sub'); }} + > + {isFishingExpanded ? '▼' : '▶'} + +
+
+
+ {isFishingExpanded && !hiddenShipCategories.has('fishing') && ( +
+ {FISHING_NAT_CATEGORIES.map(nat => { + const fCount = fishingByNationality[nat] || 0; + if (fCount === 0) return null; + return ( +
+ )} +
+ ); + } + + return ( +
+ ); +} + +// ── Aircraft categories special renderer ────────────── + +function AircraftCategoriesContent({ + aircraftByCategory, + hiddenAcCategories, + onAcCategoryToggle, + legendOpen, + toggleLegend, +}: { + aircraftByCategory: Record; + hiddenAcCategories: Set; + onAcCategoryToggle: (cat: string) => void; + legendOpen: Set; + toggleLegend: (key: string) => void; +}) { + const { t } = useTranslation(['common', 'ships']); + return ( +
+ {AC_CATEGORIES.map(cat => { + const count = aircraftByCategory[cat] || 0; + if (count === 0) return null; + return ( +
+ ); +} + +// ── Nationality categories special renderer ─────────── + +function NationalityCategoriesContent({ + shipsByNationality, + hiddenNationalities, + onNationalityToggle, +}: { + shipsByNationality: Record; + hiddenNationalities: Set; + onNationalityToggle: (nat: string) => void; +}) { + return ( +
+ {NAT_CATEGORIES.map(nat => { + const count = shipsByNationality[nat] || 0; + if (count === 0) return null; + return ( +
+ ); +} + +// ── Recursive tree renderer ─────────────────────────── + +interface LayerTreeRendererProps { + node: LayerTreeNode; + depth: number; + layers: Record; + expanded: Set; + onToggle: (key: string) => void; + onBatchToggle?: (keys: string[], value: boolean) => void; + toggleExpand: (key: string) => void; + special: SpecialRendererProps; +} + +function LayerTreeRenderer({ + node, + depth, + layers, + expanded, + onToggle, + onBatchToggle, + toggleExpand, + special, +}: LayerTreeRendererProps) { + const isLeaf = !node.children && !node.specialRenderer; + const hasSpecial = !!node.specialRenderer; + const isExpandable = !isLeaf; + const active = isLeaf ? !!layers[node.key] : isNodeActive(node, layers); + const count = isLeaf ? node.count : getTreeCount(node, layers); + const isExp = expanded.has(node.key); + + const handleToggle = () => { + if (isLeaf) { + onToggle(node.key); + return; + } + if (hasSpecial) { + // Special nodes (ships/aircraft) toggle the underlying layer key + onToggle(node.key); + return; + } + // Parent cascade: toggle all leaf keys under this node + const leaves = getAllLeafKeys(node); + const allOn = leaves.every(k => layers[k]); + if (onBatchToggle) { + onBatchToggle(leaves, !allOn); + } else { + for (const k of leaves) { + if (allOn || !layers[k]) onToggle(k); + } + } + }; + + const renderChildren = () => { + if (node.specialRenderer === 'shipCategories') { + return ( + + ); + } + if (node.specialRenderer === 'aircraftCategories') { + return ( + + ); + } + if (node.specialRenderer === 'nationalityCategories') { + if (!special.shipsByNationality || !special.hiddenNationalities || !special.onNationalityToggle) return null; + return ( + + ); + } + if (node.children) { + return ( +
+ {node.children.map(child => ( + + ))} +
+ ); + } + return null; + }; + + return ( +
0 ? { marginLeft: depth > 1 ? 12 : 0 } : undefined}> + toggleExpand(node.key) : undefined} + /> + {isExpandable && isExp && renderChildren()} +
+ ); +} + +// ── Main component ──────────────────────────────────── + export function LayerPanel({ layers, onToggle, + onBatchToggle, aircraftByCategory, aircraftTotal, shipsByMtCategory, shipTotal, satelliteCount, + tree, extraLayers, overseasItems, hiddenAcCategories, @@ -207,379 +622,277 @@ export function LayerPanel({ const _militaryCount = Object.entries(aircraftByCategory) .filter(([cat]) => cat !== 'civilian' && cat !== 'unknown') .reduce((sum, [, c]) => sum + c, 0); - void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용 + void _militaryCount; + + const specialProps: SpecialRendererProps = { + shipsByMtCategory, + hiddenShipCategories, + onShipCategoryToggle, + aircraftByCategory, + hiddenAcCategories, + onAcCategoryToggle, + fishingByNationality, + hiddenFishingNats, + onFishingNatToggle, + shipsByNationality, + hiddenNationalities, + onNationalityToggle, + legendOpen, + toggleLegend, + expanded, + toggleExpand, + }; return (

LAYERS

- {/* ═══ 선박 (최상위) ═══ */} - {/* Ships tree */} - onToggle('ships')} - onExpand={() => toggleExpand('ships')} - /> - {layers.ships && expanded.has('ships') && ( -
- {MT_CATEGORIES.map(cat => { - const count = shipsByMtCategory[cat] || 0; - if (count === 0) return null; + {tree ? ( + // ── Unified tree rendering ── + tree.map(node => ( + + )) + ) : ( + // ── Legacy rendering (backward compat) ── + <> + {/* Ships tree */} + onToggle('ships')} + onExpand={() => toggleExpand('ships')} + /> + {layers.ships && expanded.has('ships') && ( + + )} - // 어선은 국적별 하위 분류 표시 - if (cat === 'fishing' && fishingByNationality && hiddenFishingNats && onFishingNatToggle) { - const isFishingExpanded = expanded.has('fishing-sub'); - return ( -
-
- { e.stopPropagation(); toggleExpand('fishing-sub'); }} - > - {isFishingExpanded ? '▼' : '▶'} - -
-
-
- {isFishingExpanded && !hiddenShipCategories.has('fishing') && ( -
- {FISHING_NAT_CATEGORIES.map(nat => { - const fCount = fishingByNationality[nat] || 0; - if (fCount === 0) return null; - return ( -
- )} -
- ); - } - - return ( -
- )} - - {/* Nationality tree (Korea tab only) */} - {shipsByNationality && hiddenNationalities && onNationalityToggle && ( - <> + {/* 항공망 그룹 */} a + b, 0)} - color="#8b5cf6" + layerKey="group-항공망" + label="항공망" + color="#22d3ee" active expandable - isExpanded={expanded.has('nationality')} - onToggle={() => toggleExpand('nationality')} - onExpand={() => toggleExpand('nationality')} + isExpanded={expanded.has('group-항공망')} + onToggle={() => toggleExpand('group-항공망')} + onExpand={() => toggleExpand('group-항공망')} /> - {expanded.has('nationality') && ( + {expanded.has('group-항공망') && (
- {NAT_CATEGORIES.map(nat => { - const count = shipsByNationality[nat] || 0; - if (count === 0) return null; - return ( -
- )} - - )} - - {/* ═══ 항공망 그룹 ═══ */} - toggleExpand('group-항공망')} - onExpand={() => toggleExpand('group-항공망')} - /> - {expanded.has('group-항공망') && ( -
- {/* Aircraft tree */} - onToggle('aircraft')} - onExpand={() => toggleExpand('aircraft')} - /> - {layers.aircraft && expanded.has('aircraft') && ( -
- {AC_CATEGORIES.map(cat => { - const count = aircraftByCategory[cat] || 0; - if (count === 0) return null; - return ( -
- )} - {/* Satellites */} - onToggle('satellites')} - /> -
- )} - - {/* Extra layers — grouped */} - {extraLayers && (() => { - const grouped: Record = {}; - const ungrouped: ExtraLayer[] = []; - for (const el of extraLayers) { - if (el.group) { - if (!grouped[el.group]) grouped[el.group] = []; - grouped[el.group].push(el); - } else { - ungrouped.push(el); - } - } - - // 수퍼그룹 별로 그룹 분류 - const superGrouped: Record = {}; // superGroup → groupNames[] - const noSuperGroup: string[] = []; - for (const groupName of Object.keys(grouped)) { - const sg = GROUP_META[groupName]?.superGroup; - if (sg) { - if (!superGrouped[sg]) superGrouped[sg] = []; - superGrouped[sg].push(groupName); - } else { - noSuperGroup.push(groupName); - } - } - - const renderGroup = (groupName: string, indent = false) => { - const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; - const isGroupExpanded = expanded.has(`group-${groupName}`); - const items = grouped[groupName] || []; - return ( -
toggleExpand(`group-${groupName}`)} - onExpand={() => toggleExpand(`group-${groupName}`)} + isExpanded={expanded.has('aircraft')} + onToggle={() => onToggle('aircraft')} + onExpand={() => toggleExpand('aircraft')} /> - {isGroupExpanded && ( -
- {items.map(el => ( - onToggle(el.key)} - /> - ))} -
+ {layers.aircraft && expanded.has('aircraft') && ( + )} + onToggle('satellites')} + />
- ); - }; + )} - return ( - <> - {/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */} - {noSuperGroup.map(g => renderGroup(g))} + {/* Extra layers — grouped */} + {extraLayers && (() => { + const grouped: Record = {}; + const ungrouped: ExtraLayer[] = []; + for (const el of extraLayers) { + if (el.group) { + if (!grouped[el.group]) grouped[el.group] = []; + grouped[el.group].push(el); + } else { + ungrouped.push(el); + } + } - {/* 수퍼그룹으로 묶인 그룹들 */} - {Object.entries(superGrouped).map(([sgName, groupNames]) => { - const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' }; - const isSgExpanded = expanded.has(`supergroup-${sgName}`); + const superGrouped: Record = {}; + const noSuperGroup: string[] = []; + for (const groupName of Object.keys(grouped)) { + const sg = GROUP_META[groupName]?.superGroup; + if (sg) { + if (!superGrouped[sg]) superGrouped[sg] = []; + superGrouped[sg].push(groupName); + } else { + noSuperGroup.push(groupName); + } + } + + const renderGroup = (groupName: string, indent = false) => { + const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; + const isGroupExpanded = expanded.has(`group-${groupName}`); + const items = grouped[groupName] || []; return ( -
+
toggleExpand(`supergroup-${sgName}`)} - onExpand={() => toggleExpand(`supergroup-${sgName}`)} + isExpanded={isGroupExpanded} + onToggle={() => toggleExpand(`group-${groupName}`)} + onExpand={() => toggleExpand(`group-${groupName}`)} /> - {isSgExpanded && ( + {isGroupExpanded && (
- {groupNames.map(g => renderGroup(g, true))} + {items.map(el => ( + onToggle(el.key)} + /> + ))}
)}
); - })} + }; - {/* 그룹 없는 개별 레이어 */} - {ungrouped.map(el => ( - onToggle(el.key)} - /> - ))} - - ); - })()} + return ( + <> + {noSuperGroup.map(g => renderGroup(g))} + {Object.entries(superGrouped).map(([sgName, groupNames]) => { + const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' }; + const isSgExpanded = expanded.has(`supergroup-${sgName}`); + return ( +
+ toggleExpand(`supergroup-${sgName}`)} + onExpand={() => toggleExpand(`supergroup-${sgName}`)} + /> + {isSgExpanded && ( +
+ {groupNames.map(g => renderGroup(g, true))} +
+ )} +
+ ); + })} + {ungrouped.map(el => ( + onToggle(el.key)} + /> + ))} + + ); + })()} -
+
- {/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */} - toggleExpand('overseas-section')} - onExpand={() => toggleExpand('overseas-section')} - /> - {expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && ( -
- {overseasItems.map(item => ( - - ))} -
+ {/* 해외시설 */} + toggleExpand('overseas-section')} + onExpand={() => toggleExpand('overseas-section')} + /> + {expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && ( +
+ {overseasItems.map(item => ( + + ))} +
+ )} + )}
@@ -606,7 +919,6 @@ function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll const handleToggle = () => { if (hasChildren) { - // 부모 토글 → 모든 하위 리프 on/off const allLeaves: string[] = []; const collectLeaves = (node: OverseasItem) => { if (node.children?.length) node.children.forEach(collectLeaves); diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index f8da0ef..c581596 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { IRAN_OIL_COUNT } from './createIranOilLayers'; import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers'; @@ -10,7 +10,7 @@ import { GlobeMap } from './GlobeMap'; import { SatelliteMap } from './SatelliteMap'; import { SensorChart } from '../common/SensorChart'; import { EventLog } from '../common/EventLog'; -import { LayerPanel } from '../common/LayerPanel'; +import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel'; import { LiveControls } from '../common/LiveControls'; import { ReplayControls } from '../common/ReplayControls'; import { TimelineSlider } from '../common/TimelineSlider'; @@ -113,11 +113,51 @@ const IranDashboard = ({ setLayers(prev => ({ ...prev, [key]: !prev[key] })); }, []); + const batchToggleLayer = useCallback((keys: string[], value: boolean) => { + setLayers(prev => { + const next = { ...prev } as Record; + for (const k of keys) next[k] = value; + return next as LayerVisibility; + }); + }, []); + const handleEventFlyTo = useCallback((event: GeoEvent) => { setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); - const meCountByCountry = (ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length; + const meCountByCountry = useCallback((ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length, []); + + const layerTree = useMemo((): LayerTreeNode[] => [ + { key: 'ships', label: t('layers.ships'), color: '#fb923c', count: iranData.ships.length, specialRenderer: 'shipCategories' }, + { + key: 'aviation', label: '항공망', color: '#22d3ee', + children: [ + { key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: iranData.aircraft.length, specialRenderer: 'aircraftCategories' }, + { key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: iranData.satPositions.length }, + ], + }, + { 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', count: IRAN_AIRPORT_COUNT }, + { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT }, + { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT }, + { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, + { + key: 'overseas', label: '해외시설', color: '#f97316', + children: [ + { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') }, + { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') }, + { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') }, + { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') }, + { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') }, + { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') }, + { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') }, + { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') }, + { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') }, + { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') }, + ], + }, + ], [iranData, t, meCountByCountry]); // 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트 const headerSlot = document.getElementById('dashboard-header-slot'); @@ -214,31 +254,13 @@ const IranDashboard = ({ } onToggle={toggleLayer as (key: string) => void} + onBatchToggle={batchToggleLayer} + tree={layerTree} 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', count: IRAN_AIRPORT_COUNT }, - { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT }, - { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT }, - { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, - ]} - overseasItems={[ - { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') }, - { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') }, - { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') }, - { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') }, - { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') }, - { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') }, - { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') }, - { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') }, - { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') }, - { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') }, - ]} hiddenAcCategories={hiddenAcCategories} hiddenShipCategories={hiddenShipCategories} onAcCategoryToggle={toggleAcCategory} diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 93558cf..9e3eae4 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -1,10 +1,10 @@ -import { useState, useCallback } from 'react'; +import { useState, useMemo, 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'; +import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel'; import { EventLog } from '../common/EventLog'; import { LiveControls } from '../common/LiveControls'; import { ReplayControls } from '../common/ReplayControls'; @@ -125,6 +125,14 @@ export const KoreaDashboard = ({ setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); }, [setKoreaLayers]); + const batchToggleKoreaLayer = useCallback((keys: string[], value: boolean) => { + setKoreaLayers(prev => { + const next = { ...prev }; + for (const k of keys) next[k] = value; + return next; + }); + }, [setKoreaLayers]); + const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set()); const toggleNationality = useCallback((nat: string) => { setHiddenNationalities(prev => { @@ -164,6 +172,85 @@ export const KoreaDashboard = ({ // Tab switching is managed by parent (App.tsx); no-op here }, []); + const layerTree = useMemo((): LayerTreeNode[] => [ + { key: 'ships', label: t('layers.ships'), color: '#fb923c', count: koreaData.ships.length, specialRenderer: 'shipCategories' }, + { key: 'nationality', label: '국적 분류', color: '#8b5cf6', count: koreaData.ships.length, specialRenderer: 'nationalityCategories' }, + { + key: 'aviation', label: '항공망', color: '#22d3ee', + children: [ + { key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: koreaData.aircraft.length, specialRenderer: 'aircraftCategories' }, + { key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: koreaData.satPositions.length }, + ], + }, + { + key: 'maritime-safety', label: '해양안전', color: '#3b82f6', + children: [ + { key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length }, + { key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length }, + { key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length }, + { key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length }, + { key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1 }, + { key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length }, + { key: 'nkMissile', label: '미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length }, + { key: 'nkLaunch', label: '발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length }, + ], + }, + { + key: 'govt-infra', label: '국가기관망', color: '#f59e0b', + children: [ + { key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length }, + { key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length }, + { key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length }, + { key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length }, + ], + }, + { + key: 'energy', label: '에너지/발전시설', color: '#a855f7', + children: [ + { key: 'infra', label: t('layers.infra'), color: '#ffc107' }, + { key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length }, + { key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length }, + { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length }, + ], + }, + { + key: 'hazard', label: '위험시설', color: '#ef4444', + children: [ + { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length }, + { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length }, + { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length }, + { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length }, + ], + }, + { + key: 'industry', label: '산업공정/제조시설', color: '#0ea5e9', + children: [ + { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length }, + { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length }, + { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length }, + ], + }, + { + key: 'overseas', label: '해외시설', color: '#f97316', + children: [ + { + key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444', + 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', + children: [ + { key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length }, + { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length }, + ], + }, + ], + }, + ], [koreaData, t]); + // 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트 const headerSlot = document.getElementById('dashboard-header-slot'); const countsSlot = document.getElementById('dashboard-counts-slot'); @@ -247,59 +334,13 @@ export const KoreaDashboard = ({ 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} diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts index 92dfba6..9441216 100644 --- a/frontend/src/hooks/layers/createFacilityLayers.ts +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -59,9 +59,236 @@ function infraSvg(f: PowerFacility): string { `; } +// ─── Hazard SVG ─────────────────────────────────────────────────────────────── + +function nuclearSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function thermalSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function petrochemSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function lngSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function oilTankSvg(color: string, size: number): string { + return ` + + + + + + + `; +} + +function hazPortSvg(color: string, size: number): string { + return ` + + + + + `; +} + +function shipyardSvg(color: string, size: number): string { + return ` + + + + + + `; +} + +function wastewaterSvg(color: string, size: number): string { + return ` + + + + + `; +} + +function heavyIndustrySvg(color: string, size: number): string { + return ` + + + + + + + + + + + + `; +} + +// ─── Naval/Airbase/Army SVG (reused from createMilitaryLayers pattern) ───────── + +function navalFacSvg(color: string, size: number): string { + return ` + + + + + + + + `; +} + +function airbaseFacSvg(color: string, size: number): string { + return ` + + + `; +} + +function armyFacSvg(color: string, size: number): string { + return ` + + + + + `; +} + // ─── Module-level icon caches ───────────────────────────────────────────────── const infraIconCache = new Map(); +const hazardIconCache = new Map(); +const cnIconCache = new Map(); +const jpIconCache = new Map(); + +// ─── Hazard icon helpers ─────────────────────────────────────────────────────── + +const HAZARD_SVG: Record string> = { + petrochemical: petrochemSvg, + lng: lngSvg, + oilTank: oilTankSvg, + hazardPort: hazPortSvg, + nuclear: nuclearSvg, + thermal: thermalSvg, + shipyard: shipyardSvg, + wastewater: wastewaterSvg, + heavyIndustry: heavyIndustrySvg, +}; + +const HAZARD_COLOR: Record = { + petrochemical: '#f97316', + lng: '#06b6d4', + oilTank: '#eab308', + hazardPort: '#ef4444', + nuclear: '#a855f7', + thermal: '#64748b', + shipyard: '#0ea5e9', + wastewater: '#10b981', + heavyIndustry: '#94a3b8', +}; + +function getHazardIconUrl(type: string): string { + if (!hazardIconCache.has(type)) { + const color = HAZARD_COLOR[type] ?? '#888'; + const svgFn = HAZARD_SVG[type] ?? hazPortSvg; + hazardIconCache.set(type, svgToDataUri(svgFn(color, 64))); + } + return hazardIconCache.get(type)!; +} + +// ─── CN icon helpers ─────────────────────────────────────────────────────────── + +const CN_SVG: Record string> = { + nuclear: nuclearSvg, + thermal: thermalSvg, + naval: navalFacSvg, + airbase: airbaseFacSvg, + army: armyFacSvg, + shipyard: shipyardSvg, +}; + +const CN_COLOR: Record = { + nuclear: '#ef4444', + thermal: '#f97316', + naval: '#3b82f6', + airbase: '#22d3ee', + army: '#22c55e', + shipyard: '#94a3b8', +}; + +function getCnIconUrl(subType: string): string { + if (!cnIconCache.has(subType)) { + const color = CN_COLOR[subType] ?? '#888'; + const svgFn = CN_SVG[subType] ?? armyFacSvg; + cnIconCache.set(subType, svgToDataUri(svgFn(color, 64))); + } + return cnIconCache.get(subType)!; +} + +// ─── JP icon helpers ─────────────────────────────────────────────────────────── + +const JP_SVG: Record string> = { + nuclear: nuclearSvg, + thermal: thermalSvg, + naval: navalFacSvg, + airbase: airbaseFacSvg, + army: armyFacSvg, +}; + +const JP_COLOR: Record = { + nuclear: '#ef4444', + thermal: '#f97316', + naval: '#3b82f6', + airbase: '#22d3ee', + army: '#22c55e', +}; + +function getJpIconUrl(subType: string): string { + if (!jpIconCache.has(subType)) { + const color = JP_COLOR[subType] ?? '#888'; + const svgFn = JP_SVG[subType] ?? armyFacSvg; + jpIconCache.set(subType, svgToDataUri(svgFn(color, 64))); + } + return jpIconCache.get(subType)!; +} // ─── createFacilityLayers ───────────────────────────────────────────────────── @@ -148,37 +375,32 @@ export function createFacilityLayers( 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] }, + const HAZARD_META: Record = { + petrochemical: { color: [249, 115, 22, 255] }, + lng: { color: [6, 182, 212, 255] }, + oilTank: { color: [234, 179, 8, 255] }, + hazardPort: { color: [239, 68, 68, 255] }, + nuclear: { color: [168, 85, 247, 255] }, + thermal: { color: [100, 116, 139, 255] }, + shipyard: { color: [14, 165, 233, 255] }, + wastewater: { color: [16, 185, 129, 255] }, + heavyIndustry: { color: [148, 163, 184, 255] }, }; if (hazardData.length > 0) { layers.push( - new TextLayer({ - id: 'static-hazard-emoji', + new IconLayer({ + id: 'static-hazard-icon', data: hazardData, getPosition: (d) => [d.lng, d.lat], - getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', - getSize: 16 * sc, + getIcon: (d) => ({ url: getHazardIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 18 * sc, updateTriggers: { getSize: [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( @@ -196,7 +418,7 @@ export function createFacilityLayers( fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, - outlineWidth: 8, + outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', @@ -207,13 +429,13 @@ export function createFacilityLayers( // ── 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 CN_META: Record = { + nuclear: { color: [239, 68, 68, 255] }, + thermal: { color: [249, 115, 22, 255] }, + naval: { color: [59, 130, 246, 255] }, + airbase: { color: [34, 211, 238, 255] }, + army: { color: [34, 197, 94, 255] }, + shipyard: { color: [148, 163, 184, 255] }, }; const cnData: CnFacility[] = [ ...(config.cnPower ? CN_POWER_PLANTS : []), @@ -221,23 +443,18 @@ export function createFacilityLayers( ]; if (cnData.length > 0) { layers.push( - new TextLayer({ - id: 'static-cn-emoji', + new IconLayer({ + id: 'static-cn-icon', data: cnData, getPosition: (d) => [d.lng, d.lat], - getText: (d) => CN_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, + getIcon: (d) => ({ url: getCnIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 18 * sc, updateTriggers: { getSize: [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( @@ -255,7 +472,7 @@ export function createFacilityLayers( fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, - outlineWidth: 8, + outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', @@ -266,12 +483,12 @@ export function createFacilityLayers( // ── 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 JP_META: Record = { + nuclear: { color: [239, 68, 68, 255] }, + thermal: { color: [249, 115, 22, 255] }, + naval: { color: [59, 130, 246, 255] }, + airbase: { color: [34, 211, 238, 255] }, + army: { color: [34, 197, 94, 255] }, }; const jpData: JpFacility[] = [ ...(config.jpPower ? JP_POWER_PLANTS : []), @@ -279,23 +496,18 @@ export function createFacilityLayers( ]; if (jpData.length > 0) { layers.push( - new TextLayer({ - id: 'static-jp-emoji', + new IconLayer({ + id: 'static-jp-icon', data: jpData, getPosition: (d) => [d.lng, d.lat], - getText: (d) => JP_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, + getIcon: (d) => ({ url: getJpIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }), + getSize: 18 * sc, updateTriggers: { getSize: [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( @@ -313,7 +525,7 @@ export function createFacilityLayers( fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, - outlineWidth: 8, + outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto',