From dc8a30a58b35cab8d60aefaed8f63349470a7e3b Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 06:34:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20LayerPanel=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20+=20SVG=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=A0=84=EC=88=98=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LayerTreeNode 공통 인터페이스 + LayerTreeRenderer 재귀 컴포넌트 - 한국/이란 양쪽 트리 데이터 정의 + batchToggle 캐스케이드 - 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, 3 IconLayer) - 부모 토글→하위 전체 ON/OFF, 카운트 합산 동기화 - 대시보드 탭 localStorage 영속화 --- frontend/src/App.tsx | 3 +- frontend/src/components/common/LayerPanel.tsx | 988 ++++++++++++------ .../src/components/iran/IranDashboard.tsx | 68 +- .../src/components/korea/KoreaDashboard.tsx | 141 ++- .../src/hooks/layers/createFacilityLayers.ts | 318 +++++- 5 files changed, 1053 insertions(+), 465 deletions(-) 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', -- 2.45.2 From 81bced436733e68b7065204b4ba42a810551af41 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 23 Mar 2026 17:07:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(iran):=20S&P=20Global=20Marine=20Risk?= =?UTF-8?q?=20Note=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=9D=B4=EB=9E=80?= =?UTF-8?q?=20=EC=83=81=EC=84=A0=EA=B3=B5=EA=B2=A9=2027=EC=B2=99=20?= =?UTF-8?q?=ED=94=BC=EA=B2=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S&P Global Market Intelligence (2026-03-19) 보고서 기반 - 이란 상선 공격 총 30건 중 식별 가능한 27척 데이터 추가 - 선박별: IMO, 국적, 유형, 피격 일시, 위치, 피해 정도 - 유형별: 탱커 52%, 벌크선 21%, 컨테이너 17%, 예인선 7% - 해역별: UAE 48%, 오만 28%, 쿠웨이트/카타르 등 - 기존 리플레이 이벤트 ID와 연동 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/data/damagedShips.ts | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/frontend/src/data/damagedShips.ts b/frontend/src/data/damagedShips.ts index 6f6b54d..6e4efeb 100644 --- a/frontend/src/data/damagedShips.ts +++ b/frontend/src/data/damagedShips.ts @@ -145,4 +145,57 @@ export const damagedShips: DamagedShip[] = [ description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.', eventId: 'd12-us5', }, + + // ═══ S&P Global Marine Risk Note (2026-03-19) ═══ + // 이란의 상선 공격 30건 — 호르무즈 해협 중심 + // UAE 해역 48%, 오만 해역 28%, 기타 24% + + // DAY 0 — 2026-03-01 (6척 동시 공격) + { id: 'spg-01', name: 'SKYLIGHT', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.12, lng: 56.28, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9396737, 마셜제도 국적 화학탱커. UAE 해역 피격.', eventId: 'imp1' }, + { id: 'spg-02', name: 'STAR ELECTRA', flag: 'LR', type: 'Bulk Carrier', lat: 25.85, lng: 56.42, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9442536, 라이베리아 국적 벌크선. UAE 해역 피격.', eventId: 'imp1' }, + { id: 'spg-03', name: 'HERCULES STAR', flag: 'GI', type: 'Products Tanker', lat: 26.35, lng: 56.15, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9916135, 지브롤터 국적 유조선. UAE 해역.', eventId: 'imp1' }, + { id: 'spg-04', name: 'SEA LA DONNA', flag: 'LR', type: 'Chemical/Products Tanker', lat: 25.98, lng: 56.55, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9380532, 라이베리아 화학탱커.', eventId: 'imp1' }, + { id: 'spg-05', name: 'OCEAN ELECTRA', flag: 'LR', type: 'Products Tanker', lat: 26.22, lng: 56.35, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9402782, 라이베리아 유조선.', eventId: 'imp1' }, + { id: 'spg-06', name: 'AYEH', flag: 'AE', type: 'Deck Cargo Ship', lat: 25.55, lng: 55.80, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 1075181, UAE 국적 갑판화물선.', eventId: 'imp1' }, + + // DAY 1 — 2026-03-02 (3척) + { id: 'spg-07', name: 'STENA IMPERATIVE', flag: 'US', type: 'Chemical/Products Tanker', lat: 26.55, lng: 56.10, damagedAt: T0 + 1 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9666077, 미국 국적 화학탱커. 미국 선박 최초 피격.', eventId: 'imp1' }, + { id: 'spg-08', name: 'MKD VYOM', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.70, lng: 56.90, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9284386, 마셜제도 원유탱커. 오만 해역.', eventId: 'imp1' }, + { id: 'spg-09', name: 'ATHE NOVA', flag: 'HN', type: 'Asphalt/Bitumen Tanker', lat: 25.40, lng: 57.20, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9188116, 온두라스 아스팔트탱커. 오만 해역.', eventId: 'imp1' }, + + // DAY 2 — 2026-03-03 (3척) + { id: 'spg-10', name: 'PELAGIA', flag: 'MT', type: 'Bulk Carrier', lat: 26.30, lng: 56.50, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9433626, 몰타 벌크선.', eventId: 'd3-sea1' }, + { id: 'spg-11', name: 'GOLD OAK', flag: 'PA', type: 'Bulk Carrier', lat: 25.60, lng: 57.10, damagedAt: T0 + 2 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9806342, 파나마 벌크선. 오만 해역.', eventId: 'd3-sea1' }, + { id: 'spg-12', name: 'LIBRA TRADER', flag: 'IN', type: 'Crude Oil Tanker', lat: 26.05, lng: 56.30, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'severe', description: 'IMO 9562673, 인도 원유탱커.', eventId: 'd3-sea1' }, + + // DAY 3 — 2026-03-04 (3척) + { id: 'spg-13', name: 'SAFEEN PRESTIGE', flag: 'MT', type: 'Container Ship', lat: 25.90, lng: 56.40, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9593517, 몰타 컨테이너선.', eventId: 'imp2' }, + { id: 'spg-14', name: 'MSC GRACE', flag: 'LR', type: 'Container Ship', lat: 26.40, lng: 56.20, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9987366, 라이베리아 MSC 컨테이너선.', eventId: 'imp2' }, + + // DAY 4 — 2026-03-05 (1척) + { id: 'spg-15', name: 'SONANGOL NAMIBE', flag: 'BS', type: 'Crude Oil Tanker', lat: 26.15, lng: 56.45, damagedAt: T0 + 4 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9325049, 바하마 원유탱커. UAE 해역.', eventId: 'imp2' }, + + // DAY 5 — 2026-03-06 (2척) + { id: 'spg-16', name: 'PRIMA', flag: 'MT', type: 'Tanker', lat: 25.80, lng: 56.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9427433, 몰타 탱커.', eventId: 'imp3' }, + { id: 'spg-17', name: 'MUSSAFAH 2', flag: 'AE', type: 'Tug', lat: 25.50, lng: 55.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9522051, UAE 예인선.', eventId: 'imp3' }, + + // DAY 6 — 2026-03-07 (1척) + { id: 'spg-18', name: 'LOUIS P', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.60, lng: 56.05, damagedAt: T0 + 6 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9749336, 마셜제도 화학탱커.', eventId: 'imp3' }, + + // DAY 10 — 2026-03-11 (4척) + { id: 'spg-19', name: 'MAYUREE NAREE', flag: 'TH', type: 'Bulk Carrier', lat: 25.45, lng: 57.30, damagedAt: T0 + 10 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9323649, 태국 벌크선. 오만 해역.', eventId: 'd12-sea1' }, + { id: 'spg-20', name: 'STAR GWYNETH', flag: 'MH', type: 'Bulk Carrier', lat: 26.20, lng: 56.55, damagedAt: T0 + 10 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9301031, 마셜제도 벌크선.', eventId: 'd12-sea1' }, + { id: 'spg-21', name: 'ONE MAJESTY', flag: 'JP', type: 'Container Ship', lat: 25.75, lng: 56.80, damagedAt: T0 + 10 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9424912, 일본 ONE 컨테이너선. 오만 해역.', eventId: 'd12-sea1' }, + { id: 'spg-22', name: 'EXPRESS ROME', flag: 'LR', type: 'Container Ship', lat: 26.00, lng: 56.35, damagedAt: T0 + 10 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9484936, 라이베리아 컨테이너선.', eventId: 'd12-sea1' }, + + // DAY 11 — 2026-03-12 (3척) + { id: 'spg-23', name: 'ZEFYROS', flag: 'MT', type: 'Chemical/Products Tanker', lat: 26.45, lng: 56.15, damagedAt: T0 + 11 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9515917, 몰타 화학탱커.', eventId: 'd12-sea3' }, + { id: 'spg-24', name: 'SAFESEA VISHNU', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.65, lng: 57.00, damagedAt: T0 + 11 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9327009, 마셜제도 원유탱커. 오만 해역.', eventId: 'd12-sea3' }, + { id: 'spg-25', name: 'SOURCE BLESSING', flag: 'LR', type: 'Container Ship', lat: 26.10, lng: 56.40, damagedAt: T0 + 11 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9243198, 라이베리아 컨테이너선.', eventId: 'd12-sea3' }, + + // DAY 15 — 2026-03-16 (1척) + { id: 'spg-26', name: 'GAS AL AHMADIAH', flag: 'KW', type: 'LPG Tanker', lat: 29.20, lng: 48.80, damagedAt: T0 + 15 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9849629, 쿠웨이트 LPG탱커. 쿠웨이트 해역.', eventId: 'd12-sea6' }, + + // DAY 18 — 2026-03-19 (2척) + { id: 'spg-27', name: 'HALUL 69', flag: 'QA', type: 'Anchor Handling Tug Supply', lat: 25.95, lng: 51.55, damagedAt: T0 + 18 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9671577, 카타르 AHTS. 카타르 해역.', eventId: 'd12-p5' }, ]; -- 2.45.2 From 9e1b3730ff044ef46c31c3ba563159ac6ccad4c2 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 07:52:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(backend):=20=EC=9D=B4=EB=9E=80=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=8B=9C=EC=A0=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20+=20Events=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aircraft/OSINT Controller: from/to Instant 파라미터 추가 (기존 캐시 조회와 공존) - AircraftService.getByDateRange(): DB에서 icao24별 최신 위치 조회 - OsintService.getByDateRange(): 날짜 범위 OSINT 조회 - Event 패키지 신규: Entity, Dto, Repository, Service, Controller - GET /api/events?from=&to= (인증 예외) - POST /api/events/import (벌크 import) - AuthFilter: /api/events 인증 예외 추가 --- .../main/java/gc/mda/kcg/auth/AuthFilter.java | 4 +- .../domain/aircraft/AircraftController.java | 12 ++++- .../aircraft/AircraftPositionRepository.java | 11 ++++ .../kcg/domain/aircraft/AircraftService.java | 51 +++++++++++++++++++ .../java/gc/mda/kcg/domain/event/Event.java | 42 +++++++++++++++ .../mda/kcg/domain/event/EventController.java | 34 +++++++++++++ .../gc/mda/kcg/domain/event/EventDto.java | 49 ++++++++++++++++++ .../mda/kcg/domain/event/EventRepository.java | 13 +++++ .../gc/mda/kcg/domain/event/EventService.java | 43 ++++++++++++++++ .../mda/kcg/domain/osint/OsintController.java | 12 ++++- .../kcg/domain/osint/OsintFeedRepository.java | 2 + .../gc/mda/kcg/domain/osint/OsintService.java | 25 +++++++++ 12 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/Event.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventService.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index 18c4370..78ae2ae 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -26,6 +26,7 @@ public class AuthFilter extends OncePerRequestFilter { private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis"; private static final String PREDICTION_PATH_PREFIX = "/api/prediction/"; private static final String FLEET_PATH_PREFIX = "/api/fleet-"; + private static final String EVENTS_PATH_PREFIX = "/api/events"; private final JwtProvider jwtProvider; @@ -37,7 +38,8 @@ public class AuthFilter extends OncePerRequestFilter { || path.startsWith(CCTV_PATH_PREFIX) || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX) || path.startsWith(PREDICTION_PATH_PREFIX) - || path.startsWith(FLEET_PATH_PREFIX); + || path.startsWith(FLEET_PATH_PREFIX) + || path.startsWith(EVENTS_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java index 1846d1e..dc08546 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java @@ -2,12 +2,14 @@ package gc.mda.kcg.domain.aircraft; import gc.mda.kcg.collector.aircraft.AircraftCacheStore; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,16 +22,24 @@ public class AircraftController { private static final Set VALID_REGIONS = Set.of("iran", "korea"); private final AircraftCacheStore cacheStore; + private final AircraftService aircraftService; @GetMapping public ResponseEntity> getAircraft( - @RequestParam(defaultValue = "iran") String region) { + @RequestParam(defaultValue = "iran") String region, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { if (!VALID_REGIONS.contains(region)) { return ResponseEntity.badRequest() .body(Map.of("error", "유효하지 않은 region: " + region)); } + if (from != null && to != null) { + List results = aircraftService.getByDateRange(region, from, to); + return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results)); + } + List aircraft = cacheStore.get(region); long lastUpdated = cacheStore.getLastUpdated(region); diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java index 2442340..9e6a100 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java @@ -1,6 +1,17 @@ package gc.mda.kcg.domain.aircraft; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; public interface AircraftPositionRepository extends JpaRepository { + + @Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC") + List findByRegionAndDateRange( + @Param("region") String region, + @Param("from") Instant from, + @Param("to") Instant to); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java new file mode 100644 index 0000000..f6f0824 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.aircraft; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AircraftService { + + private final AircraftPositionRepository repository; + + /** + * 시간 범위 내 항공기 위치를 조회하고 icao24 기준 최신 위치로 중복 제거하여 반환. + */ + public List getByDateRange(String region, Instant from, Instant to) { + List positions = repository.findByRegionAndDateRange(region, from, to); + + Map deduplicated = new LinkedHashMap<>(); + for (AircraftPosition p : positions) { + deduplicated.putIfAbsent(p.getIcao24(), toDto(p)); + } + return List.copyOf(deduplicated.values()); + } + + private AircraftDto toDto(AircraftPosition p) { + return AircraftDto.builder() + .icao24(p.getIcao24()) + .callsign(p.getCallsign()) + .lat(p.getPosition() != null ? p.getPosition().getY() : 0.0) + .lng(p.getPosition() != null ? p.getPosition().getX() : 0.0) + .altitude(p.getAltitude() != null ? p.getAltitude() : 0.0) + .velocity(p.getVelocity() != null ? p.getVelocity() : 0.0) + .heading(p.getHeading() != null ? p.getHeading() : 0.0) + .verticalRate(p.getVerticalRate() != null ? p.getVerticalRate() : 0.0) + .onGround(p.getOnGround() != null && p.getOnGround()) + .category(p.getCategory()) + .typecode(p.getTypecode()) + .typeDesc(p.getTypeDesc()) + .registration(p.getRegistration()) + .operator(p.getOperator()) + .squawk(p.getSquawk()) + .lastSeen(p.getLastSeen() != null ? p.getLastSeen().toEpochMilli() + : p.getCollectedAt().toEpochMilli()) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/Event.java b/backend/src/main/java/gc/mda/kcg/domain/event/Event.java new file mode 100644 index 0000000..385d22e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/Event.java @@ -0,0 +1,42 @@ +package gc.mda.kcg.domain.event; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "events", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", unique = true) + private String eventId; + + private String title; + private String description; + private String source; + + @Column(name = "latitude") + private Double latitude; + + @Column(name = "longitude") + private Double longitude; + + private Instant timestamp; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map rawData; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java new file mode 100644 index 0000000..b72bfad --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java @@ -0,0 +1,34 @@ +package gc.mda.kcg.domain.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/events") +@RequiredArgsConstructor +public class EventController { + + private final EventService eventService; + + @GetMapping + public ResponseEntity> getEvents( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { + Instant f = from != null ? from : Instant.parse("2026-03-01T00:00:00Z"); + Instant t = to != null ? to : Instant.now(); + List results = eventService.getByDateRange(f, t); + return ResponseEntity.ok(Map.of("count", results.size(), "items", results)); + } + + @PostMapping("/import") + public ResponseEntity> importEvents(@RequestBody List events) { + int imported = eventService.importEvents(events); + return ResponseEntity.ok(Map.of("imported", imported, "total", events.size())); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java new file mode 100644 index 0000000..61105ea --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java @@ -0,0 +1,49 @@ +package gc.mda.kcg.domain.event; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EventDto { + + private String id; + private long timestamp; + private Double lat; + private Double lng; + private String type; + private String source; + private String label; + private String description; + private Integer intensity; + + public static EventDto from(Event e) { + return EventDto.builder() + .id(e.getEventId()) + .timestamp(e.getTimestamp() != null ? e.getTimestamp().toEpochMilli() : 0) + .lat(e.getLatitude()) + .lng(e.getLongitude()) + .type(extractType(e)) + .source(e.getSource()) + .label(e.getTitle()) + .description(e.getDescription()) + .intensity(extractIntensity(e)) + .build(); + } + + private static String extractType(Event e) { + if (e.getRawData() != null && e.getRawData().containsKey("type")) { + return String.valueOf(e.getRawData().get("type")); + } + return "alert"; + } + + private static Integer extractIntensity(Event e) { + if (e.getRawData() != null && e.getRawData().containsKey("intensity")) { + return ((Number) e.getRawData().get("intensity")).intValue(); + } + return 50; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java new file mode 100644 index 0000000..1c21e14 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java @@ -0,0 +1,13 @@ +package gc.mda.kcg.domain.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface EventRepository extends JpaRepository { + + List findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to); + + boolean existsByEventId(String eventId); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java new file mode 100644 index 0000000..050bc54 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java @@ -0,0 +1,43 @@ +package gc.mda.kcg.domain.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class EventService { + + private final EventRepository repository; + + public List getByDateRange(Instant from, Instant to) { + return repository.findByTimestampBetweenOrderByTimestampAsc(from, to) + .stream().map(EventDto::from).toList(); + } + + public int importEvents(List dtos) { + int count = 0; + for (EventDto dto : dtos) { + if (dto.getId() != null && repository.existsByEventId(dto.getId())) continue; + Event e = Event.builder() + .eventId(dto.getId()) + .title(dto.getLabel()) + .description(dto.getDescription()) + .source(dto.getSource()) + .latitude(dto.getLat()) + .longitude(dto.getLng()) + .timestamp(Instant.ofEpochMilli(dto.getTimestamp())) + .rawData(Map.of( + "type", dto.getType() != null ? dto.getType() : "alert", + "intensity", dto.getIntensity() != null ? dto.getIntensity() : 50 + )) + .build(); + repository.save(e); + count++; + } + return count; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java index bed9fb7..2419520 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java @@ -4,12 +4,14 @@ import gc.mda.kcg.config.CacheConfig; import lombok.RequiredArgsConstructor; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -23,18 +25,26 @@ public class OsintController { private static final Set VALID_REGIONS = Set.of("iran", "korea"); private final CacheManager cacheManager; + private final OsintService osintService; private final Map lastUpdated = new ConcurrentHashMap<>(); @GetMapping public ResponseEntity> getOsint( - @RequestParam(defaultValue = "iran") String region) { + @RequestParam(defaultValue = "iran") String region, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { if (!VALID_REGIONS.contains(region)) { return ResponseEntity.badRequest() .body(Map.of("error", "유효하지 않은 region: " + region)); } + if (from != null && to != null) { + List results = osintService.getByDateRange(region, from, to); + return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results)); + } + String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA; List items = getCachedItems(cacheName); long updatedAt = lastUpdated.getOrDefault(region, 0L); diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java index d3355aa..a548686 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -12,4 +12,6 @@ public interface OsintFeedRepository extends JpaRepository { boolean existsByTitle(String title); List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); + + List findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java new file mode 100644 index 0000000..1a7d544 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java @@ -0,0 +1,25 @@ +package gc.mda.kcg.domain.osint; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OsintService { + + private final OsintFeedRepository repository; + + /** + * 시간 범위 내 OSINT 피드를 조회하여 반환. + * focus(region) 필드 기준 필터링, publishedAt 기준 정렬. + */ + public List getByDateRange(String region, Instant from, Instant to) { + return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to) + .stream() + .map(OsintDto::from) + .toList(); + } +} -- 2.45.2 From 6d4ac4d3fef28aa8548bda3e32f4ee32de240ce7 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 07:52:22 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(frontend):=20=EC=9D=B4=EB=9E=80=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=8B=A4=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=84=ED=99=98=20+=20=ED=94=BC?= =?UTF-8?q?=EA=B2=A9=EC=84=A0=EB=B0=95=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeoEvent.type에 'sea_attack' 추가 + SEA ATK 배지 (#0ea5e9) - damagedShips → GeoEvent 변환, mergedEvents에 합류 - 더미↔API 토글 UI (ReplayControls 배속 우측) - useIranData: dataSource 분기 (dummy=sampleData, api=Backend DB) - API 모드: events/aircraft/osint 시점 범위 조회 (3월1일~오늘) - 중복 방지: API 모드에서 damageEvents 프론트 병합 건너뜀 - fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수 --- frontend/src/App.css | 4 ++ frontend/src/components/common/EventLog.tsx | 2 + frontend/src/components/common/EventStrip.tsx | 1 + .../src/components/common/ReplayControls.tsx | 24 +++++++ .../src/components/common/TimelineSlider.tsx | 1 + frontend/src/components/iran/GlobeMap.tsx | 1 + .../src/components/iran/IranDashboard.tsx | 7 +- frontend/src/components/iran/ReplayMap.tsx | 1 + frontend/src/components/iran/SatelliteMap.tsx | 1 + frontend/src/hooks/useIranData.ts | 67 ++++++++++++++----- frontend/src/services/aircraftApi.ts | 14 ++++ frontend/src/services/api.ts | 28 ++++++-- frontend/src/services/osint.ts | 26 +++++++ frontend/src/types.ts | 2 +- 14 files changed, 157 insertions(+), 22 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 6ac030e..8ec542e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1431,6 +1431,10 @@ gap: 3px; margin-left: 8px; } +.data-source-toggle { + border-left: 1px solid rgba(255,255,255,0.15); + padding-left: 8px; +} .speed-btn { padding: 3px 8px; diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index b1ae5b2..0caf308 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -260,6 +260,7 @@ const TYPE_LABELS: Record = { alert: 'ALERT', impact: 'IMPACT', osint: 'OSINT', + sea_attack: 'SEA ATK', }; const TYPE_COLORS: Record = { @@ -270,6 +271,7 @@ const TYPE_COLORS: Record = { alert: 'var(--kcg-event-alert)', impact: 'var(--kcg-event-impact)', osint: 'var(--kcg-event-osint)', + sea_attack: '#0ea5e9', }; // MarineTraffic-style ship type classification diff --git a/frontend/src/components/common/EventStrip.tsx b/frontend/src/components/common/EventStrip.tsx index 922a14a..602c9d0 100644 --- a/frontend/src/components/common/EventStrip.tsx +++ b/frontend/src/components/common/EventStrip.tsx @@ -20,6 +20,7 @@ const TYPE_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const TYPE_KEYS: Record = { diff --git a/frontend/src/components/common/ReplayControls.tsx b/frontend/src/components/common/ReplayControls.tsx index d90b05e..51b3dc9 100644 --- a/frontend/src/components/common/ReplayControls.tsx +++ b/frontend/src/components/common/ReplayControls.tsx @@ -1,6 +1,8 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +export type DataSource = 'dummy' | 'api'; + interface Props { isPlaying: boolean; speed: number; @@ -11,6 +13,8 @@ interface Props { onReset: () => void; onSpeedChange: (speed: number) => void; onRangeChange: (start: number, end: number) => void; + dataSource?: DataSource; + onDataSourceChange?: (ds: DataSource) => void; } const SPEEDS = [1, 2, 4, 8, 16]; @@ -51,6 +55,8 @@ export function ReplayControls({ onReset, onSpeedChange, onRangeChange, + dataSource, + onDataSourceChange, }: Props) { const { t } = useTranslation(); const [showPicker, setShowPicker] = useState(false); @@ -110,6 +116,24 @@ export function ReplayControls({ ))}
+ {/* Data source toggle */} + {dataSource && onDataSourceChange && ( +
+ + +
+ )} + {/* Spacer */}
diff --git a/frontend/src/components/common/TimelineSlider.tsx b/frontend/src/components/common/TimelineSlider.tsx index 27cc837..6b5bbd9 100644 --- a/frontend/src/components/common/TimelineSlider.tsx +++ b/frontend/src/components/common/TimelineSlider.tsx @@ -21,6 +21,7 @@ const TYPE_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const TYPE_I18N_KEYS: Record = { diff --git a/frontend/src/components/iran/GlobeMap.tsx b/frontend/src/components/iran/GlobeMap.tsx index 682f9cd..011b8e0 100644 --- a/frontend/src/components/iran/GlobeMap.tsx +++ b/frontend/src/components/iran/GlobeMap.tsx @@ -21,6 +21,7 @@ const EVENT_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; // Navy flag-based colors for military vessels diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index c581596..421bfe3 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback } from 'react'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; import { createPortal } from 'react-dom'; import { IRAN_OIL_COUNT } from './createIranOilLayers'; import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers'; @@ -12,7 +13,7 @@ import { SensorChart } from '../common/SensorChart'; import { EventLog } from '../common/EventLog'; import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel'; import { LiveControls } from '../common/LiveControls'; -import { ReplayControls } from '../common/ReplayControls'; +import { ReplayControls, type DataSource } from '../common/ReplayControls'; import { TimelineSlider } from '../common/TimelineSlider'; import { useIranData } from '../../hooks/useIranData'; import { useSharedFilters } from '../../hooks/useSharedFilters'; @@ -95,6 +96,7 @@ const IranDashboard = ({ const [flyToTarget, setFlyToTarget] = useState(null); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); + const [dataSource, setDataSource] = useLocalStorage('iranDataSource', 'dummy'); const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = useSharedFilters(); @@ -107,6 +109,7 @@ const IranDashboard = ({ hiddenShipCategories, refreshKey, dashboardTab: 'iran', + dataSource, }); const toggleLayer = useCallback((key: keyof LayerVisibility) => { @@ -331,6 +334,8 @@ const IranDashboard = ({ onReset={replay.reset} onSpeedChange={replay.setSpeed} onRangeChange={replay.setRange} + dataSource={dataSource} + onDataSourceChange={setDataSource} /> = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const SOURCE_COLORS: Record = { diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 1aacc2c..e1d6512 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -74,6 +74,7 @@ const EVENT_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const SOURCE_COLORS: Record = { diff --git a/frontend/src/hooks/useIranData.ts b/frontend/src/hooks/useIranData.ts index cd38e8d..334d8f8 100644 --- a/frontend/src/hooks/useIranData.ts +++ b/frontend/src/hooks/useIranData.ts @@ -1,16 +1,19 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { fetchEvents } from '../services/api'; -import { fetchAircraftFromBackend } from '../services/aircraftApi'; +import { fetchEvents, fetchEventsByRange } from '../services/api'; +import { fetchAircraftFromBackend, fetchAircraftByRange } from '../services/aircraftApi'; import { getSampleAircraft } from '../data/sampleAircraft'; import { fetchSatelliteTLE, propagateAll } from '../services/celestrak'; import { fetchShips } from '../services/ships'; -import { fetchOsintFeed } from '../services/osint'; +import { fetchOsintFeed, fetchOsintByRange } from '../services/osint'; import type { OsintItem } from '../services/osint'; import { fetchSeismic, fetchPressure } from '../services/sensorApi'; import type { SeismicDto, PressureDto } from '../services/sensorApi'; import { propagateAircraft, propagateShips } from '../services/propagation'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; +import { damagedShips } from '../data/damagedShips'; + +type DataSource = 'dummy' | 'api'; interface UseIranDataArgs { appMode: AppMode; @@ -20,6 +23,7 @@ interface UseIranDataArgs { hiddenShipCategories: Set; refreshKey: number; dashboardTab: 'iran' | 'korea'; + dataSource?: DataSource; } interface UseIranDataResult { @@ -52,7 +56,10 @@ export function useIranData({ hiddenShipCategories, refreshKey, dashboardTab, + dataSource = 'dummy', }: UseIranDataArgs): UseIranDataResult { + const IRAN_T0 = '2026-03-01T00:00:00Z'; + const isApi = dataSource === 'api'; const [events, setEvents] = useState([]); const [seismicData, setSeismicData] = useState([]); const [pressureData, setPressureData] = useState([]); @@ -66,11 +73,15 @@ export function useIranData({ const sensorInitRef = useRef(false); const shipMapRef = useRef>(new Map()); - // Load initial data + // Load initial data (events + satellites) useEffect(() => { - fetchEvents().then(setEvents).catch(() => {}); + if (isApi) { + fetchEventsByRange(IRAN_T0, new Date().toISOString()).then(setEvents).catch(() => {}); + } else { + fetchEvents().then(setEvents).catch(() => {}); + } fetchSatelliteTLE().then(setSatellites).catch(() => {}); - }, [refreshKey]); + }, [refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Sensor data: initial full 48h load + 10min polling (incremental merge) useEffect(() => { @@ -103,20 +114,23 @@ export function useIranData({ return () => clearInterval(interval); }, [refreshKey]); - // Fetch base aircraft data (LIVE: backend, REPLAY: sample) + // Fetch base aircraft data (LIVE: backend, REPLAY: sample or API) useEffect(() => { const load = async () => { if (appMode === 'live') { const result = await fetchAircraftFromBackend('iran'); if (result.length > 0) setBaseAircraft(result); + } else if (isApi) { + const result = await fetchAircraftByRange('iran', IRAN_T0, new Date().toISOString()); + if (result.length > 0) setBaseAircraft(result); } else { setBaseAircraft(getSampleAircraft()); } }; load(); - const interval = setInterval(load, 60_000); + const interval = setInterval(load, appMode === 'live' ? 60_000 : 300_000); return () => clearInterval(interval); - }, [appMode, refreshKey]); + }, [appMode, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch Iran ship data: initial 60min, then 5min polling with 6min window + merge + stale cleanup const mergeShips = useCallback((newShips: Ship[]) => { @@ -164,15 +178,20 @@ export function useIranData({ if (!shouldFetch) { setOsintFeed([]); return; } const load = async () => { try { - const data = await fetchOsintFeed('iran'); + let data: OsintItem[]; + if (isApi && !isLive) { + data = await fetchOsintByRange('iran', IRAN_T0, new Date().toISOString()); + } else { + data = await fetchOsintFeed('iran'); + } if (data.length > 0) setOsintFeed(data); } catch { /* keep previous */ } }; setOsintFeed([]); load(); - const interval = setInterval(load, 120_000); + const interval = setInterval(load, isApi ? 300_000 : 120_000); return () => clearInterval(interval); - }, [isLive, dashboardTab, refreshKey]); + }, [isLive, dashboardTab, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Propagate satellite positions — throttle to every 2s of real time useEffect(() => { @@ -259,11 +278,27 @@ export function useIranData({ }); }, [osintFeed, dashboardTab]); - // 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬) + // 피격 선박 → GeoEvent 변환 + const damageEvents = useMemo(() => + damagedShips.map(s => ({ + id: `dmg-${s.id}`, + timestamp: s.damagedAt, + lat: s.lat, + lng: s.lng, + type: 'sea_attack' as const, + source: s.flag === 'IR' ? 'IR' as const : undefined, + label: `${s.name} (${s.flag}) — ${s.cause}`, + description: s.description, + intensity: s.damage === 'sunk' ? 100 : s.damage === 'severe' ? 75 : s.damage === 'moderate' ? 50 : 25, + })), + []); + + // 기본 이벤트 + OSINT 이벤트 + 피격 선박 병합 (시간순 정렬) + // API 모드: DB에 이미 sampleEvents+damagedShips 포함 → damageEvents 중복 방지 const mergedEvents = useMemo(() => { - if (osintEvents.length === 0) return events; - return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp); - }, [events, osintEvents]); + const extra = isApi ? [] : damageEvents; + return [...events, ...osintEvents, ...extra].sort((a, b) => a.timestamp - b.timestamp); + }, [events, osintEvents, damageEvents, isApi]); // Aircraft stats const aircraftByCategory = useMemo(() => { diff --git a/frontend/src/services/aircraftApi.ts b/frontend/src/services/aircraftApi.ts index 951978d..3aacce1 100644 --- a/frontend/src/services/aircraftApi.ts +++ b/frontend/src/services/aircraftApi.ts @@ -19,3 +19,17 @@ export async function fetchAircraftFromBackend(region: 'iran' | 'korea'): Promis return []; } } + +/** 시점 범위 조회 (리플레이용) */ +export async function fetchAircraftByRange(region: string, from: string, to: string): Promise { + try { + const res = await fetch(`/api/kcg/aircraft?region=${region}&from=${from}&to=${to}`, { + credentials: 'include', + }); + if (!res.ok) return []; + const data = await res.json(); + return data.items ?? data.aircraft ?? []; + } catch { + return []; + } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7b175d1..9ad7701 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,13 +9,33 @@ const defaultConfig: ApiConfig = { let cachedSensorData: SensorLog[] | null = null; -export async function fetchEvents(_config?: Partial): Promise { - // In production, replace with actual API call: - // const res = await fetch(config.eventsEndpoint); - // return res.json(); +export async function fetchEvents(_config?: Partial): Promise { + // 더미 모드: sampleEvents 반환 return Promise.resolve(sampleEvents); } +/** Backend DB에서 이벤트 범위 조회 (API 모드 리플레이용) */ +export async function fetchEventsByRange(from: string, to: string): Promise { + try { + const res = await fetch(`/api/kcg/events?from=${from}&to=${to}`); + if (!res.ok) return []; + const data = await res.json(); + return (data.items ?? []).map((d: Record) => ({ + id: String(d.id ?? ''), + timestamp: Number(d.timestamp ?? 0), + lat: Number(d.lat ?? 0), + lng: Number(d.lng ?? 0), + type: (String(d.type ?? 'alert')) as GeoEvent['type'], + source: d.source ? String(d.source) as GeoEvent['source'] : undefined, + label: String(d.label ?? ''), + description: d.description ? String(d.description) : undefined, + intensity: d.intensity != null ? Number(d.intensity) : undefined, + })); + } catch { + return []; + } +} + export async function fetchSensorData(_config?: Partial): Promise { // In production, replace with actual API call: // const res = await fetch(config.sensorEndpoint); diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index 1304c45..32520b9 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -945,3 +945,29 @@ export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise< return unique.slice(0, 50); // cap at 50 items } + +/** 시점 범위 조회 (리플레이용) — Backend DB에서 날짜 범위로 OSINT 조회 */ +export async function fetchOsintByRange(region: string, from: string, to: string): Promise { + try { + const res = await fetch(`/api/kcg/osint?region=${region}&from=${from}&to=${to}`, { + credentials: 'include', + }); + if (!res.ok) return []; + const data = await res.json(); + const items = data.items ?? []; + return items.map((d: Record) => ({ + id: String(d.id ?? ''), + timestamp: Number(d.timestamp ?? 0), + title: String(d.title ?? ''), + source: String(d.source ?? ''), + url: String(d.url ?? ''), + category: (d.category as OsintItem['category']) ?? 'general', + language: (d.language as OsintItem['language']) ?? 'other', + imageUrl: d.imageUrl ? String(d.imageUrl) : undefined, + lat: d.lat != null ? Number(d.lat) : undefined, + lng: d.lng != null ? Number(d.lng) : undefined, + })); + } catch { + return []; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5be101f..685fecc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3,7 +3,7 @@ export interface GeoEvent { timestamp: number; // unix ms lat: number; lng: number; - type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint'; + type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint' | 'sea_attack'; source?: 'US' | 'IL' | 'IR' | 'proxy'; // 공격 주체: 미국, 이스라엘, 이란, 대리세력 label: string; description?: string; -- 2.45.2 From 44aa449b0350b4818d883e809fae788bba77b621 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 09:27:11 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=20=ED=81=AC=EA=B8=B0=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20(4=EA=B0=9C=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위 - LAYERS 패널 하단 슬라이더 UI, localStorage 영속화 - Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용 - MapLibre 선박 라벨/국가명 실시간 반영 - 모든 useMemo deps + updateTriggers에 fontScale 포함 --- docs/RELEASE-NOTES.md | 14 ++++++ frontend/src/App.css | 39 ++++++++++++++++ frontend/src/App.tsx | 3 ++ .../src/components/common/FontScalePanel.tsx | 45 +++++++++++++++++++ frontend/src/components/common/LayerPanel.tsx | 2 + .../components/iran/MEEnergyHazardLayer.tsx | 4 +- frontend/src/components/iran/ReplayMap.tsx | 18 ++++---- frontend/src/components/iran/SatelliteMap.tsx | 18 ++++---- .../iran/createIranAirportLayers.ts | 4 +- .../components/iran/createIranOilLayers.ts | 4 +- .../components/iran/createMEFacilityLayers.ts | 4 +- frontend/src/components/korea/KoreaMap.tsx | 30 +++++++------ frontend/src/components/layers/ShipLayer.tsx | 8 +++- frontend/src/contexts/FontScaleContext.tsx | 10 +++++ frontend/src/contexts/fontScaleState.ts | 14 ++++++ .../src/hooks/layers/createFacilityLayers.ts | 16 +++---- .../src/hooks/layers/createMilitaryLayers.ts | 16 +++---- .../hooks/layers/createNavigationLayers.ts | 16 +++---- frontend/src/hooks/layers/createPortLayers.ts | 8 ++-- frontend/src/hooks/layers/types.ts | 3 +- frontend/src/hooks/useAnalysisDeckLayers.ts | 11 +++-- frontend/src/hooks/useFontScale.ts | 4 ++ frontend/src/hooks/useStaticDeckLayers.ts | 5 ++- 23 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/common/FontScalePanel.tsx create mode 100644 frontend/src/contexts/FontScaleContext.tsx create mode 100644 frontend/src/contexts/fontScaleState.ts create mode 100644 frontend/src/hooks/useFontScale.ts diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 639afdd..c72ce2e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,20 @@ ## [Unreleased] +### 추가 +- LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용) +- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer) +- S&P Global 피격 선박 27척 데이터 (damagedShips.ts) +- 이란 리플레이 실데이터 전환: Backend 시점 조회 API + Events CRUD +- GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합) +- 더미↔API 토글 UI (리플레이 배속 우측) +- 대시보드 탭 localStorage 영속화 + +### 변경 +- 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산 +- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘) +- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수 + ## [2026-03-23.6] ### 수정 diff --git a/frontend/src/App.css b/frontend/src/App.css index 8ec542e..259b72c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2462,3 +2462,42 @@ text-align: center; opacity: 0.5; } + +/* ── FontScalePanel ──────────────────────── */ +.font-scale-section { margin-top: 4px; } +.font-scale-toggle { + width: 100%; + padding: 4px 8px; + font-size: 10px; + color: var(--kcg-text); + background: transparent; + border: none; + border-top: 1px solid rgba(255,255,255,0.08); + cursor: pointer; + display: flex; + justify-content: space-between; +} +.font-scale-toggle:hover { background: rgba(255,255,255,0.05); } +.font-scale-sliders { padding: 4px 8px; } +.font-scale-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 9px; + color: var(--kcg-dim); + margin-bottom: 3px; +} +.font-scale-row label { width: 60px; flex-shrink: 0; } +.font-scale-row input[type="range"] { flex: 1; height: 12px; accent-color: var(--kcg-primary, #3b82f6); } +.font-scale-row span { width: 24px; text-align: right; font-variant-numeric: tabular-nums; } +.font-scale-reset { + width: 100%; + padding: 2px; + font-size: 9px; + color: var(--kcg-dim); + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 3px; + cursor: pointer; + margin-top: 4px; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e53296..bddd0be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; import { SharedFilterProvider } from './contexts/SharedFilterContext'; +import { FontScaleProvider } from './contexts/FontScaleContext'; import { IranDashboard } from './components/iran/IranDashboard'; import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; @@ -65,6 +66,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime; return ( +
@@ -158,6 +160,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { )}
+
); } diff --git a/frontend/src/components/common/FontScalePanel.tsx b/frontend/src/components/common/FontScalePanel.tsx new file mode 100644 index 0000000..c027f0c --- /dev/null +++ b/frontend/src/components/common/FontScalePanel.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { useFontScale } from '../../hooks/useFontScale'; +import type { FontScaleConfig } from '../../contexts/fontScaleState'; + +const LABELS: Record = { + facility: '시설 라벨', + ship: '선박 이름', + analysis: '분석 라벨', + area: '지역/국가명', +}; + +export function FontScalePanel() { + const { fontScale, setFontScale } = useFontScale(); + const [open, setOpen] = useState(false); + + const update = (key: keyof FontScaleConfig, val: number) => { + setFontScale({ ...fontScale, [key]: Math.round(val * 10) / 10 }); + }; + + return ( +
+ + {open && ( +
+ {(Object.keys(LABELS) as (keyof FontScaleConfig)[]).map(key => ( +
+ + update(key, parseFloat(e.target.value))} /> + {fontScale[key].toFixed(1)} +
+ ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index f538203..d9e9f2c 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocalStorageSet } from '../../hooks/useLocalStorage'; +import { FontScalePanel } from './FontScalePanel'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -895,6 +896,7 @@ export function LayerPanel({ )}
+
); } diff --git a/frontend/src/components/iran/MEEnergyHazardLayer.tsx b/frontend/src/components/iran/MEEnergyHazardLayer.tsx index 8e8d542..21bfd2b 100644 --- a/frontend/src/components/iran/MEEnergyHazardLayer.tsx +++ b/frontend/src/components/iran/MEEnergyHazardLayer.tsx @@ -54,6 +54,7 @@ export { layerKeyToSubType, layerKeyToCountry }; export interface MELayerConfig { layers: Record; sc: number; + fs?: number; onPick: (facility: EnergyHazardFacility) => void; } @@ -174,6 +175,7 @@ function getIconUrl(subType: FacilitySubType): string { export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { const { layers, sc, onPick } = config; + const fs = config.fs ?? 1; const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f => isFacilityVisible(f, layers), @@ -200,7 +202,7 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { data: visibleFacilities, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index 16fbaaa..76e350d 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { useFontScale } from '../../hooks/useFontScale'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; @@ -128,6 +129,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la const [selectedEventId, setSelectedEventId] = useState(null); const [mePickedFacility, setMePickedFacility] = useState(null); const [iranPickedFacility, setIranPickedFacility] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(5); const zoomRef = useRef(5); @@ -154,11 +156,11 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la }, [zoomLevel]); const iranDeckLayers = useMemo(() => [ - ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), - ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), - ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), - ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), - ], [layers, zoomScale]); + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }), + ], [layers, zoomScale, fontScale.facility]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -242,7 +244,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la filter={['==', ['get', 'rank'], 1]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -261,7 +263,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la filter={['==', ['get', 'rank'], 2]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -281,7 +283,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la minzoom={5} layout={{ 'text-field': ['get', 'name'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-allow-overlap': false, 'text-ignore-placement': false, diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index e1d6512..49a0182 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { useFontScale } from '../../hooks/useFontScale'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; @@ -111,6 +112,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, const [selectedEventId, setSelectedEventId] = useState(null); const [mePickedFacility, setMePickedFacility] = useState(null); const [iranPickedFacility, setIranPickedFacility] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(5); const zoomRef = useRef(5); @@ -137,11 +139,11 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, }, [zoomLevel]); const iranDeckLayers = useMemo(() => [ - ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), - ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), - ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), - ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), - ], [layers, zoomScale]); + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }), + ], [layers, zoomScale, fontScale.facility]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -234,7 +236,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-allow-overlap': false, 'text-ignore-placement': false, }} @@ -251,7 +253,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-allow-overlap': false, }} paint={{ @@ -268,7 +270,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-allow-overlap': false, }} paint={{ diff --git a/frontend/src/components/iran/createIranAirportLayers.ts b/frontend/src/components/iran/createIranAirportLayers.ts index e668201..bed298a 100644 --- a/frontend/src/components/iran/createIranAirportLayers.ts +++ b/frontend/src/components/iran/createIranAirportLayers.ts @@ -51,11 +51,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { export interface IranAirportLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (airport: Airport) => void; } export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -84,7 +86,7 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] const nameKo = d.nameKo ?? d.name; return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo; }, - getSize: 11 * sc, + getSize: 11 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(getAirportColor(d)), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/createIranOilLayers.ts b/frontend/src/components/iran/createIranOilLayers.ts index a42a137..6a62d7a 100644 --- a/frontend/src/components/iran/createIranOilLayers.ts +++ b/frontend/src/components/iran/createIranOilLayers.ts @@ -108,11 +108,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { export interface IranOilLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (facility: OilFacility) => void; } export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -134,7 +136,7 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { data: iranOilFacilities, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(TYPE_COLORS[d.type]), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/createMEFacilityLayers.ts b/frontend/src/components/iran/createMEFacilityLayers.ts index c7a01d9..ddd0d07 100644 --- a/frontend/src/components/iran/createMEFacilityLayers.ts +++ b/frontend/src/components/iran/createMEFacilityLayers.ts @@ -103,11 +103,13 @@ function getIconUrl(type: MEFacilityType): string { export interface MEFacilityLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (facility: MEFacility) => void; } export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -129,7 +131,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { data: ME_FACILITIES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), getTextAnchor: 'middle', diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 4ae9f0f..2a48307 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; 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 { useFontScale } from '../../hooks/useFontScale'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; @@ -149,6 +150,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); const [selectedGearData, setSelectedGearData] = useState(null); const [selectedFleetData, setSelectedFleetData] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const zoomRef = useRef(KOREA_MAP_ZOOM); const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { @@ -242,7 +244,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: illegalFishingData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name || d.mmsi, - getSize: 11 * zoomScale, + getSize: 11 * zoomScale * fontScale.analysis, getColor: [239, 68, 68, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -253,8 +255,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', - updateTriggers: { getSize: [zoomScale] }, - }), [illegalFishingData, zoomScale]); + updateTriggers: { getSize: [zoomScale, fontScale.analysis] }, + }), [illegalFishingData, zoomScale, fontScale.analysis]); // 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시 const zoneLabelsLayer = useMemo(() => { @@ -281,7 +283,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data, getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat], getText: (d: { name: string }) => d.name, - getSize: 14 * zoomScale, + getSize: 14 * zoomScale * fontScale.area, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -292,9 +294,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', - updateTriggers: { getSize: [zoomScale] }, + updateTriggers: { getSize: [zoomScale, fontScale.area] }, }); - }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]); + }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]); // 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등 const staticDeckLayers = useStaticDeckLayers({ @@ -357,7 +359,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: gears, getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => d.name || d.mmsi, - getSize: 10 * zoomScale, + getSize: 10 * zoomScale * fontScale.analysis, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -392,7 +394,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: [parent], getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`, - getSize: 11 * zoomScale, + getSize: 11 * zoomScale * fontScale.analysis, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -409,7 +411,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return layers; - }, [selectedGearData, zoomScale]); + }, [selectedGearData, zoomScale, fontScale.analysis]); // 선택된 선단 소속 선박 강조 레이어 (deck.gl) const selectedFleetLayers = useMemo(() => { @@ -457,7 +459,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const prefix = role === 'LEADER' ? '★ ' : ''; return `${prefix}${d.name || d.mmsi}`; }, - getSize: 10 * zoomScale, + getSize: 10 * zoomScale * fontScale.analysis, getColor: color, getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -495,7 +497,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return result; - }, [selectedFleetData, zoomScale, vesselAnalysis]); + }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]); // 분석 결과 deck.gl 레이어 const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' @@ -527,7 +529,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF filter={['==', ['get', 'rank'], 1]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -546,7 +548,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF filter={['==', ['get', 'rank'], 2]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -566,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF minzoom={5} layout={{ 'text-field': ['get', 'name'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-allow-overlap': false, 'text-ignore-placement': false, diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 230af22..49221d8 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -6,6 +6,7 @@ import maplibregl from 'maplibre-gl'; import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getNationalityGroup } from '../../hooks/useKoreaData'; +import { useFontScale } from '../../hooks/useFontScale'; interface Props { ships: Ship[]; @@ -274,6 +275,8 @@ function ensureTriangleImage(map: maplibregl.Map) { // ── Main layer (WebGL symbol rendering — triangles) ── export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) { const { current: map } = useMap(); + const { fontScale } = useFontScale(); + const sfs = fontScale.ship; const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); const highlightKorean = !!koreanOnly; @@ -479,13 +482,14 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM /> {/* Korean ship label — always mounted, visibility으로 제어 */} {children}; +} diff --git a/frontend/src/contexts/fontScaleState.ts b/frontend/src/contexts/fontScaleState.ts new file mode 100644 index 0000000..025bb57 --- /dev/null +++ b/frontend/src/contexts/fontScaleState.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; + +export interface FontScaleConfig { + facility: number; + ship: number; + analysis: number; + area: number; +} + +export const DEFAULT_FONT_SCALE: FontScaleConfig = { facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 }; + +export const FontScaleCtx = createContext<{ fontScale: FontScaleConfig; setFontScale: (c: FontScaleConfig) => void }>({ + fontScale: DEFAULT_FONT_SCALE, setFontScale: () => {}, +}); diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts index 9441216..5cb8c02 100644 --- a/frontend/src/hooks/layers/createFacilityLayers.ts +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -353,8 +353,8 @@ export function createFacilityLayers( data: plants, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -409,8 +409,8 @@ export function createFacilityLayers( data: hazardData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -463,8 +463,8 @@ export function createFacilityLayers( data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -516,8 +516,8 @@ export function createFacilityLayers( data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts index cc0160c..1202303 100644 --- a/frontend/src/hooks/layers/createMilitaryLayers.ts +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -312,8 +312,8 @@ export function createMilitaryLayers( data: MILITARY_BASES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -350,8 +350,8 @@ export function createMilitaryLayers( data: GOV_BUILDINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -388,8 +388,8 @@ export function createMilitaryLayers( data: NK_LAUNCH_SITES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -480,8 +480,8 @@ export function createMilitaryLayers( data: impactData, getPosition: (d) => [d.lng, d.lat], getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts index cc8f3a2..dc146fa 100644 --- a/frontend/src/hooks/layers/createNavigationLayers.ts +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -174,8 +174,8 @@ export function createNavigationLayers( if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; }, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -225,8 +225,8 @@ export function createNavigationLayers( data: KOREAN_AIRPORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -275,8 +275,8 @@ export function createNavigationLayers( data: NAV_WARNINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.id, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -326,8 +326,8 @@ export function createNavigationLayers( data: PIRACY_ZONES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts index c9e27e4..dccdea4 100644 --- a/frontend/src/hooks/layers/createPortLayers.ts +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -95,8 +95,8 @@ export function createPortLayers( data: EAST_ASIA_PORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('항', ''), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -133,8 +133,8 @@ export function createPortLayers( data: KOREA_WIND_FARMS, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/types.ts b/frontend/src/hooks/layers/types.ts index 3f179ec..97ff285 100644 --- a/frontend/src/hooks/layers/types.ts +++ b/frontend/src/hooks/layers/types.ts @@ -32,7 +32,8 @@ export interface StaticPickInfo { } export interface LayerFactoryConfig { - sc: number; // sizeScale + sc: number; // sizeScale (zoom-based) + fs: number; // fontScale (user preference, default 1.0) onPick: (info: StaticPickInfo) => void; } diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index 13bfe8a..76a7bcd 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import type { Ship, VesselAnalysisDto } from '../types'; +import { useFontScale } from './useFontScale'; interface AnalyzedShip { ship: Ship; @@ -57,6 +58,8 @@ export function useAnalysisDeckLayers( activeFilter: string | null, sizeScale: number = 1.0, ): Layer[] { + const { fontScale } = useFontScale(); + const afs = fontScale.analysis; // 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨 const { riskData, darkData, spoofData } = useMemo(() => { if (analysisMap.size === 0) { @@ -123,7 +126,7 @@ export function useAnalysisDeckLayers( const name = d.ship.name || d.ship.mmsi; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; }, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, updateTriggers: { getSize: [sizeScale] }, getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getTextAnchor: 'middle', @@ -167,7 +170,7 @@ export function useAnalysisDeckLayers( const gap = d.dto.algorithms.darkVessel.gapDurationMin; return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK'; }, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, updateTriggers: { getSize: [sizeScale] }, getColor: [168, 85, 247, 255], getTextAnchor: 'middle', @@ -191,7 +194,7 @@ export function useAnalysisDeckLayers( data: spoofData, getPosition: (d) => [d.ship.lng, d.ship.lat], getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, getColor: [239, 68, 68, 255], getTextAnchor: 'start', getPixelOffset: [12, -8], @@ -207,5 +210,5 @@ export function useAnalysisDeckLayers( } return layers; - }, [riskData, darkData, spoofData, sizeScale, activeFilter]); + }, [riskData, darkData, spoofData, sizeScale, activeFilter, afs]); } diff --git a/frontend/src/hooks/useFontScale.ts b/frontend/src/hooks/useFontScale.ts new file mode 100644 index 0000000..7c2247c --- /dev/null +++ b/frontend/src/hooks/useFontScale.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { FontScaleCtx } from '../contexts/fontScaleState'; + +export function useFontScale() { return useContext(FontScaleCtx); } diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index e18589c..822c9b4 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { Layer } from '@deck.gl/core'; import type { PowerFacility } from '../services/infra'; import type { HazardType } from '../data/hazardFacilities'; +import { useFontScale } from './useFontScale'; // Re-export types for consumers export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types'; @@ -34,8 +35,9 @@ interface StaticLayerConfig { } export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { + const { fontScale } = useFontScale(); return useMemo(() => { - const fc = { sc: config.sizeScale ?? 1.0, onPick: config.onPick }; + const fc = { sc: config.sizeScale ?? 1.0, fs: fontScale.facility, onPick: config.onPick }; return [ ...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc), @@ -81,5 +83,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.jpMilitary, config.onPick, config.sizeScale, + fontScale.facility, ]); } -- 2.45.2 From 6c91655dfbeec4cbf512314aca3fa07d02ae24d8 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 09:28:08 +0900 Subject: [PATCH 6/7] =?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=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index c72ce2e..7799fc5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -12,6 +12,7 @@ - GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합) - 더미↔API 토글 UI (리플레이 배속 우측) - 대시보드 탭 localStorage 영속화 +- 지도 글꼴 크기 커스텀: 시설/선박/분석/지역 4그룹 슬라이더 (0.5~2.0x, LAYERS 하단) ### 변경 - 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산 -- 2.45.2 From 911a7473befb1b49343ee7e2ff0bc2a259d65332 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 09:29:02 +0900 Subject: [PATCH 7/7] =?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-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7799fc5..cf9486f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-24] + ### 추가 - LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용) - 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer) -- 2.45.2