feat: LayerPanel 공통 트리 구조 + SVG 아이콘 전수 전환

- LayerTreeNode 공통 인터페이스 + LayerTreeRenderer 재귀 컴포넌트
- 한국/이란 양쪽 트리 데이터 정의 + batchToggle 캐스케이드
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, 3 IconLayer)
- 부모 토글→하위 전체 ON/OFF, 카운트 합산 동기화
- 대시보드 탭 localStorage 영속화
This commit is contained in:
htlee 2026-03-24 06:34:42 +09:00
부모 13bdebb924
커밋 dc8a30a58b
5개의 변경된 파일1053개의 추가작업 그리고 465개의 파일을 삭제

파일 보기

@ -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<AppMode>('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');

파일 보기

@ -100,6 +100,19 @@ const NAT_COLORS: Record<string, string> = {
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<string,
return count;
}
// ── Tree helpers ──────────────────────────────────────
function getAllLeafKeys(node: LayerTreeNode): string[] {
if (!node.children) return [node.key];
return node.children.flatMap(getAllLeafKeys);
}
function isNodeActive(node: LayerTreeNode, layers: Record<string, boolean>): boolean {
if (!node.children) return !!layers[node.key];
return node.children.some(c => isNodeActive(c, layers));
}
function getTreeCount(node: LayerTreeNode, layers: Record<string, boolean>): 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<string, boolean>;
onToggle: (key: string) => void;
onBatchToggle?: (keys: string[], value: boolean) => void;
aircraftByCategory: Record<string, number>;
aircraftTotal: number;
shipsByMtCategory: Record<string, number>;
shipTotal: number;
satelliteCount: number;
tree?: LayerTreeNode[];
extraLayers?: ExtraLayer[];
overseasItems?: OverseasItem[];
hiddenAcCategories: Set<string>;
@ -163,76 +201,61 @@ interface LayerPanelProps {
onFishingNatToggle?: (nat: string) => void;
}
export function LayerPanel({
layers,
onToggle,
aircraftByCategory,
aircraftTotal,
// ── Special renderer props (shared across recursive calls) ──
interface SpecialRendererProps {
shipsByMtCategory: Record<string, number>;
hiddenShipCategories: Set<string>;
onShipCategoryToggle: (cat: string) => void;
aircraftByCategory: Record<string, number>;
hiddenAcCategories: Set<string>;
onAcCategoryToggle: (cat: string) => void;
fishingByNationality?: Record<string, number>;
hiddenFishingNats?: Set<string>;
onFishingNatToggle?: (nat: string) => void;
shipsByNationality?: Record<string, number>;
hiddenNationalities?: Set<string>;
onNationalityToggle?: (nat: string) => void;
legendOpen: Set<string>;
toggleLegend: (key: string) => void;
expanded: Set<string>;
toggleExpand: (key: string) => void;
}
// ── Ship categories special renderer ─────────────────
function ShipCategoriesContent({
shipsByMtCategory,
shipTotal,
satelliteCount,
extraLayers,
overseasItems,
hiddenAcCategories,
hiddenShipCategories,
onAcCategoryToggle,
onShipCategoryToggle,
shipsByNationality,
hiddenNationalities,
onNationalityToggle,
fishingByNationality,
hiddenFishingNats,
onFishingNatToggle,
}: LayerPanelProps) {
shipsByNationality,
legendOpen,
toggleLegend,
expanded,
toggleExpand,
}: {
shipsByMtCategory: Record<string, number>;
hiddenShipCategories: Set<string>;
onShipCategoryToggle: (cat: string) => void;
fishingByNationality?: Record<string, number>;
hiddenFishingNats?: Set<string>;
onFishingNatToggle?: (nat: string) => void;
shipsByNationality?: Record<string, number>;
legendOpen: Set<string>;
toggleLegend: (key: string) => void;
expanded: Set<string>;
toggleExpand: (key: string) => void;
}) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((key: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, [setExpanded]);
const toggleLegend = useCallback((key: string) => {
setLegendOpen(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, []);
const _militaryCount = Object.entries(aircraftByCategory)
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
.reduce((sum, [, c]) => sum + c, 0);
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
{/* ═══ 선박 (최상위) ═══ */}
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={t('layers.ships')}
count={shipTotal}
color="#fb923c"
active={layers.ships}
expandable
isExpanded={expanded.has('ships')}
onToggle={() => onToggle('ships')}
onExpand={() => toggleExpand('ships')}
/>
{layers.ships && expanded.has('ships') && (
<div className="layer-tree-children">
{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 (
@ -288,7 +311,6 @@ export function LayerPanel({
);
})}
{/* Ship type legend (Korea tab only) */}
{shipsByNationality && (
<>
<button
@ -320,71 +342,27 @@ export function LayerPanel({
)}
</>
)}
</div>
)}
{/* Nationality tree (Korea tab only) */}
{shipsByNationality && hiddenNationalities && onNationalityToggle && (
<>
<LayerTreeItem
layerKey="nationality"
label="국적 분류"
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
color="#8b5cf6"
active
expandable
isExpanded={expanded.has('nationality')}
onToggle={() => toggleExpand('nationality')}
onExpand={() => toggleExpand('nationality')}
/>
{expanded.has('nationality') && (
<div className="layer-tree-children">
{NAT_CATEGORIES.map(nat => {
const count = shipsByNationality[nat] || 0;
if (count === 0) return null;
return (
<CategoryToggle
key={nat}
label={NAT_LABELS[nat] || nat}
color={NAT_COLORS[nat] || '#888'}
count={count}
hidden={hiddenNationalities.has(nat)}
onClick={() => onNationalityToggle(nat)}
/>
);
})}
</div>
)}
</>
)}
}
{/* ═══ 항공망 그룹 ═══ */}
<LayerTreeItem
layerKey="group-항공망"
label="항공망"
color="#22d3ee"
active
expandable
isExpanded={expanded.has('group-항공망')}
onToggle={() => toggleExpand('group-항공망')}
onExpand={() => toggleExpand('group-항공망')}
/>
{expanded.has('group-항공망') && (
<div className="layer-tree-children">
{/* Aircraft tree */}
<LayerTreeItem
layerKey="aircraft"
label={t('layers.aircraft')}
count={aircraftTotal}
color="#22d3ee"
active={layers.aircraft}
expandable
isExpanded={expanded.has('aircraft')}
onToggle={() => onToggle('aircraft')}
onExpand={() => toggleExpand('aircraft')}
/>
{layers.aircraft && expanded.has('aircraft') && (
// ── Aircraft categories special renderer ──────────────
function AircraftCategoriesContent({
aircraftByCategory,
hiddenAcCategories,
onAcCategoryToggle,
legendOpen,
toggleLegend,
}: {
aircraftByCategory: Record<string, number>;
hiddenAcCategories: Set<string>;
onAcCategoryToggle: (cat: string) => void;
legendOpen: Set<string>;
toggleLegend: (key: string) => void;
}) {
const { t } = useTranslation(['common', 'ships']);
return (
<div className="layer-tree-children">
{AC_CATEGORIES.map(cat => {
const count = aircraftByCategory[cat] || 0;
@ -431,8 +409,347 @@ export function LayerPanel({
</div>
)}
</div>
);
}
// ── Nationality categories special renderer ───────────
function NationalityCategoriesContent({
shipsByNationality,
hiddenNationalities,
onNationalityToggle,
}: {
shipsByNationality: Record<string, number>;
hiddenNationalities: Set<string>;
onNationalityToggle: (nat: string) => void;
}) {
return (
<div className="layer-tree-children">
{NAT_CATEGORIES.map(nat => {
const count = shipsByNationality[nat] || 0;
if (count === 0) return null;
return (
<CategoryToggle
key={nat}
label={NAT_LABELS[nat] || nat}
color={NAT_COLORS[nat] || '#888'}
count={count}
hidden={hiddenNationalities.has(nat)}
onClick={() => onNationalityToggle(nat)}
/>
);
})}
</div>
);
}
// ── Recursive tree renderer ───────────────────────────
interface LayerTreeRendererProps {
node: LayerTreeNode;
depth: number;
layers: Record<string, boolean>;
expanded: Set<string>;
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 (
<ShipCategoriesContent
shipsByMtCategory={special.shipsByMtCategory}
hiddenShipCategories={special.hiddenShipCategories}
onShipCategoryToggle={special.onShipCategoryToggle}
fishingByNationality={special.fishingByNationality}
hiddenFishingNats={special.hiddenFishingNats}
onFishingNatToggle={special.onFishingNatToggle}
shipsByNationality={special.shipsByNationality}
legendOpen={special.legendOpen}
toggleLegend={special.toggleLegend}
expanded={special.expanded}
toggleExpand={special.toggleExpand}
/>
);
}
if (node.specialRenderer === 'aircraftCategories') {
return (
<AircraftCategoriesContent
aircraftByCategory={special.aircraftByCategory}
hiddenAcCategories={special.hiddenAcCategories}
onAcCategoryToggle={special.onAcCategoryToggle}
legendOpen={special.legendOpen}
toggleLegend={special.toggleLegend}
/>
);
}
if (node.specialRenderer === 'nationalityCategories') {
if (!special.shipsByNationality || !special.hiddenNationalities || !special.onNationalityToggle) return null;
return (
<NationalityCategoriesContent
shipsByNationality={special.shipsByNationality}
hiddenNationalities={special.hiddenNationalities}
onNationalityToggle={special.onNationalityToggle}
/>
);
}
if (node.children) {
return (
<div className="layer-tree-children">
{node.children.map(child => (
<LayerTreeRenderer
key={child.key}
node={child}
depth={depth + 1}
layers={layers}
expanded={expanded}
onToggle={onToggle}
onBatchToggle={onBatchToggle}
toggleExpand={toggleExpand}
special={special}
/>
))}
</div>
);
}
return null;
};
return (
<div style={depth > 0 ? { marginLeft: depth > 1 ? 12 : 0 } : undefined}>
<LayerTreeItem
layerKey={node.key}
label={node.label}
color={node.color}
active={active}
expandable={isExpandable}
isExpanded={isExp}
count={count}
onToggle={handleToggle}
onExpand={isExpandable ? () => toggleExpand(node.key) : undefined}
/>
{isExpandable && isExp && renderChildren()}
</div>
);
}
// ── Main component ────────────────────────────────────
export function LayerPanel({
layers,
onToggle,
onBatchToggle,
aircraftByCategory,
aircraftTotal,
shipsByMtCategory,
shipTotal,
satelliteCount,
tree,
extraLayers,
overseasItems,
hiddenAcCategories,
hiddenShipCategories,
onAcCategoryToggle,
onShipCategoryToggle,
shipsByNationality,
hiddenNationalities,
onNationalityToggle,
fishingByNationality,
hiddenFishingNats,
onFishingNatToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((key: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, [setExpanded]);
const toggleLegend = useCallback((key: string) => {
setLegendOpen(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, []);
const _militaryCount = Object.entries(aircraftByCategory)
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
.reduce((sum, [, c]) => sum + c, 0);
void _militaryCount;
const specialProps: SpecialRendererProps = {
shipsByMtCategory,
hiddenShipCategories,
onShipCategoryToggle,
aircraftByCategory,
hiddenAcCategories,
onAcCategoryToggle,
fishingByNationality,
hiddenFishingNats,
onFishingNatToggle,
shipsByNationality,
hiddenNationalities,
onNationalityToggle,
legendOpen,
toggleLegend,
expanded,
toggleExpand,
};
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
{tree ? (
// ── Unified tree rendering ──
tree.map(node => (
<LayerTreeRenderer
key={node.key}
node={node}
depth={0}
layers={layers}
expanded={expanded}
onToggle={onToggle}
onBatchToggle={onBatchToggle}
toggleExpand={toggleExpand}
special={specialProps}
/>
))
) : (
// ── Legacy rendering (backward compat) ──
<>
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={t('layers.ships')}
count={shipTotal}
color="#fb923c"
active={layers.ships}
expandable
isExpanded={expanded.has('ships')}
onToggle={() => onToggle('ships')}
onExpand={() => toggleExpand('ships')}
/>
{layers.ships && expanded.has('ships') && (
<ShipCategoriesContent
shipsByMtCategory={shipsByMtCategory}
hiddenShipCategories={hiddenShipCategories}
onShipCategoryToggle={onShipCategoryToggle}
fishingByNationality={fishingByNationality}
hiddenFishingNats={hiddenFishingNats}
onFishingNatToggle={onFishingNatToggle}
shipsByNationality={shipsByNationality}
legendOpen={legendOpen}
toggleLegend={toggleLegend}
expanded={expanded}
toggleExpand={toggleExpand}
/>
)}
{/* Nationality tree (Korea tab only) */}
{shipsByNationality && hiddenNationalities && onNationalityToggle && (
<>
<LayerTreeItem
layerKey="nationality"
label="국적 분류"
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
color="#8b5cf6"
active
expandable
isExpanded={expanded.has('nationality')}
onToggle={() => toggleExpand('nationality')}
onExpand={() => toggleExpand('nationality')}
/>
{expanded.has('nationality') && (
<NationalityCategoriesContent
shipsByNationality={shipsByNationality}
hiddenNationalities={hiddenNationalities}
onNationalityToggle={onNationalityToggle}
/>
)}
</>
)}
{/* 항공망 그룹 */}
<LayerTreeItem
layerKey="group-항공망"
label="항공망"
color="#22d3ee"
active
expandable
isExpanded={expanded.has('group-항공망')}
onToggle={() => toggleExpand('group-항공망')}
onExpand={() => toggleExpand('group-항공망')}
/>
{expanded.has('group-항공망') && (
<div className="layer-tree-children">
<LayerTreeItem
layerKey="aircraft"
label={t('layers.aircraft')}
count={aircraftTotal}
color="#22d3ee"
active={layers.aircraft}
expandable
isExpanded={expanded.has('aircraft')}
onToggle={() => onToggle('aircraft')}
onExpand={() => toggleExpand('aircraft')}
/>
{layers.aircraft && expanded.has('aircraft') && (
<AircraftCategoriesContent
aircraftByCategory={aircraftByCategory}
hiddenAcCategories={hiddenAcCategories}
onAcCategoryToggle={onAcCategoryToggle}
legendOpen={legendOpen}
toggleLegend={toggleLegend}
/>
)}
{/* Satellites */}
<LayerTreeItem
layerKey="satellites"
label={t('layers.satellites')}
@ -457,8 +774,7 @@ export function LayerPanel({
}
}
// 수퍼그룹 별로 그룹 분류
const superGrouped: Record<string, string[]> = {}; // superGroup → groupNames[]
const superGrouped: Record<string, string[]> = {};
const noSuperGroup: string[] = [];
for (const groupName of Object.keys(grouped)) {
const sg = GROUP_META[groupName]?.superGroup;
@ -507,10 +823,7 @@ export function LayerPanel({
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}`);
@ -534,8 +847,6 @@ export function LayerPanel({
</div>
);
})}
{/* 그룹 없는 개별 레이어 */}
{ungrouped.map(el => (
<LayerTreeItem
key={el.key}
@ -553,7 +864,7 @@ export function LayerPanel({
<div className="layer-divider" />
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
{/* 해외시설 */}
<LayerTreeItem
layerKey="overseas-section"
label="해외시설"
@ -581,6 +892,8 @@ export function LayerPanel({
))}
</div>
)}
</>
)}
</div>
</div>
);
@ -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);

파일 보기

@ -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<string, boolean>;
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 = ({
<LayerPanel
layers={layers as unknown as Record<string, boolean>}
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}

파일 보기

@ -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 = ({
<LayerPanel
layers={koreaLayers}
onToggle={toggleKoreaLayer}
onBatchToggle={batchToggleKoreaLayer}
tree={layerTree}
aircraftByCategory={koreaData.aircraftByCategory}
aircraftTotal={koreaData.aircraft.length}
shipsByMtCategory={koreaData.shipsByCategory}
shipTotal={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length}
extraLayers={[
// 해양안전
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
// 국가기관망
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
// 에너지/발전시설
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => 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}

파일 보기

@ -59,9 +59,236 @@ function infraSvg(f: PowerFacility): string {
</svg>`;
}
// ─── Hazard SVG ───────────────────────────────────────────────────────────────
function nuclearSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="12" r="2" fill="${color}"/>
<path d="M12 10 Q10 7 7 7 Q6 9 7 11 Q9 12 12 12" fill="${color}" opacity="0.7"/>
<path d="M13.7 11 Q16 9 17 7 Q15 5 13 6 Q11 8 12 10" fill="${color}" opacity="0.7"/>
<path d="M10.3 13 Q7 13 6 16 Q8 18 11 17 Q13 15 13.7 13" fill="${color}" opacity="0.7"/>
<path d="M13.7 13 Q15 16 17 17 Q19 15 18 12 Q16 11 13.7 12" fill="${color}" opacity="0.7"/>
</svg>`;
}
function thermalSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="5" y="11" width="14" height="7" rx="1" fill="${color}" opacity="0.6"/>
<rect x="7" y="7" width="2" height="5" fill="${color}" opacity="0.6"/>
<rect x="11" y="5" width="2" height="7" fill="${color}" opacity="0.6"/>
<rect x="15" y="8" width="2" height="4" fill="${color}" opacity="0.6"/>
<path d="M8 5 Q8.5 3.5 9 5 Q9.5 3 10 5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.8"/>
</svg>`;
}
function petrochemSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="10" y="5" width="4" height="8" rx="1" fill="${color}" opacity="0.65"/>
<ellipse cx="12" cy="14.5" rx="4.5" ry="2.5" fill="${color}" opacity="0.75"/>
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
<path d="M7 10 Q5.5 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
</svg>`;
}
function lngSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="9" y1="7" x2="9" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="12" y1="5" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="15" y1="7" x2="15" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1"/>
<ellipse cx="12" cy="15" rx="5" ry="3.5" fill="${color}" opacity="0.65"/>
<line x1="12" y1="10" x2="12" y2="11.5" stroke="${color}" stroke-width="1"/>
</svg>`;
}
function oilTankSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<ellipse cx="12" cy="8" rx="5" ry="2" fill="${color}" opacity="0.5"/>
<rect x="7" y="8" width="10" height="8" fill="${color}" opacity="0.6"/>
<ellipse cx="12" cy="16" rx="5" ry="2" fill="${color}" opacity="0.8"/>
<line x1="9" y1="8" x2="9" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
<line x1="15" y1="8" x2="15" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
</svg>`;
}
function hazPortSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 L20 18 L4 18 Z" fill="${color}" opacity="0.7"/>
<line x1="12" y1="10" x2="12" y2="14" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="12" cy="16" r="1" fill="#fff"/>
</svg>`;
}
function shipyardSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M6 15 Q7 13 9 13 L15 13 Q17 13 18 15 L17 17 Q12 19 7 17 Z" fill="${color}" opacity="0.75"/>
<line x1="12" y1="6" x2="12" y2="13" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="6" x2="16" y2="10" stroke="${color}" stroke-width="1.2"/>
<line x1="16" y1="10" x2="16" y2="13" stroke="${color}" stroke-width="1"/>
</svg>`;
}
function wastewaterSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 Q15 9 15 12 Q15 15.3 12 17 Q9 15.3 9 12 Q9 9 12 5 Z" fill="${color}" opacity="0.75"/>
<path d="M9 14 Q10.5 15.5 12 16" fill="none" stroke="#fff" stroke-width="0.7" opacity="0.5"/>
<path d="M8.5 12 Q9.5 13.5 11 14" fill="none" stroke="#fff" stroke-width="0.7" opacity="0.5"/>
</svg>`;
}
function heavyIndustrySvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="12" r="4" fill="none" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="12" r="1.5" fill="${color}"/>
<line x1="12" y1="6" x2="12" y2="8" stroke="${color}" stroke-width="1.5"/>
<line x1="12" y1="16" x2="12" y2="18" stroke="${color}" stroke-width="1.5"/>
<line x1="6" y1="12" x2="8" y2="12" stroke="${color}" stroke-width="1.5"/>
<line x1="16" y1="12" x2="18" y2="12" stroke="${color}" stroke-width="1.5"/>
<line x1="7.8" y1="7.8" x2="9.2" y2="9.2" stroke="${color}" stroke-width="1.2"/>
<line x1="14.8" y1="14.8" x2="16.2" y2="16.2" stroke="${color}" stroke-width="1.2"/>
<line x1="16.2" y1="7.8" x2="14.8" y2="9.2" stroke="${color}" stroke-width="1.2"/>
<line x1="9.2" y1="14.8" x2="7.8" y2="16.2" stroke="${color}" stroke-width="1.2"/>
</svg>`;
}
// ─── Naval/Airbase/Army SVG (reused from createMilitaryLayers pattern) ─────────
function navalFacSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
</svg>`;
}
function airbaseFacSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
fill="${color}" stroke="#fff" stroke-width="0.3"/>
</svg>`;
}
function armyFacSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M6 10 Q6 8 8 8 L12 7 L16 8 Q18 8 18 10 L18 12 Q18 15 12 17 Q6 15 6 12 Z" fill="${color}" opacity="0.75"/>
<line x1="9" y1="10" x2="15" y2="10" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
<line x1="8.5" y1="12" x2="15.5" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
</svg>`;
}
// ─── Module-level icon caches ─────────────────────────────────────────────────
const infraIconCache = new Map<string, string>();
const hazardIconCache = new Map<string, string>();
const cnIconCache = new Map<string, string>();
const jpIconCache = new Map<string, string>();
// ─── Hazard icon helpers ───────────────────────────────────────────────────────
const HAZARD_SVG: Record<string, (c: string, s: number) => string> = {
petrochemical: petrochemSvg,
lng: lngSvg,
oilTank: oilTankSvg,
hazardPort: hazPortSvg,
nuclear: nuclearSvg,
thermal: thermalSvg,
shipyard: shipyardSvg,
wastewater: wastewaterSvg,
heavyIndustry: heavyIndustrySvg,
};
const HAZARD_COLOR: Record<string, string> = {
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, (c: string, s: number) => string> = {
nuclear: nuclearSvg,
thermal: thermalSvg,
naval: navalFacSvg,
airbase: airbaseFacSvg,
army: armyFacSvg,
shipyard: shipyardSvg,
};
const CN_COLOR: Record<string, string> = {
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, (c: string, s: number) => string> = {
nuclear: nuclearSvg,
thermal: thermalSvg,
naval: navalFacSvg,
airbase: airbaseFacSvg,
army: armyFacSvg,
};
const JP_COLOR: Record<string, string> = {
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<string, { icon: string; color: [number, number, number, number] }> = {
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<string, { color: [number, number, number, number] }> = {
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<HazardFacility>({
id: 'static-hazard-emoji',
new IconLayer<HazardFacility>({
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<HazardFacility>) => {
if (info.object) onPick({ kind: 'hazard', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(
@ -207,13 +429,13 @@ export function createFacilityLayers(
// ── CN Facilities ──────────────────────────────────────────────────────
{
const CN_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
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<string, { color: [number, number, number, number] }> = {
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<CnFacility>({
id: 'static-cn-emoji',
new IconLayer<CnFacility>({
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<CnFacility>) => {
if (info.object) onPick({ kind: 'cnFacility', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(
@ -266,12 +483,12 @@ export function createFacilityLayers(
// ── JP Facilities ──────────────────────────────────────────────────────
{
const JP_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
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<string, { color: [number, number, number, number] }> = {
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<JpFacility>({
id: 'static-jp-emoji',
new IconLayer<JpFacility>({
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<JpFacility>) => {
if (info.object) onPick({ kind: 'jpFacility', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(