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 = { fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff', cargo: '#a78bfa', civilian: '#FFD700', unknown: '#7CFC00', }; // Altitude color legend (matches AircraftLayer gradient) const ALT_LEGEND: [string, string][] = [ ['Ground', '#555555'], ['< 2,000ft', '#00c000'], ['2,000ft', '#55EC55'], ['4,000ft', '#7CFC00'], ['6,000ft', '#BFFF00'], ['10,000ft', '#FFFF00'], ['20,000ft', '#FFD700'], ['30,000ft', '#FF8C00'], ['40,000ft', '#FF4500'], ['50,000ft+', '#BA55D3'], ]; // Military color legend const MIL_LEGEND: [string, string][] = [ ['Fighter', '#ff4444'], ['Military', '#ff6600'], ['ISR / Surveillance', '#ffcc00'], ['Tanker', '#00ccff'], ]; // Ship MT category color (matches ShipLayer MT_TYPE_COLORS) const MT_CAT_COLORS: Record = { cargo: 'var(--kcg-ship-cargo)', tanker: 'var(--kcg-ship-tanker)', passenger: 'var(--kcg-ship-passenger)', fishing: 'var(--kcg-ship-fishing)', fishing_gear: '#f97316', military: 'var(--kcg-ship-military)', tug_special: 'var(--kcg-ship-tug)', high_speed: 'var(--kcg-ship-highspeed)', pleasure: 'var(--kcg-ship-pleasure)', other: 'var(--kcg-ship-other)', unspecified: 'var(--kcg-ship-unknown)', unknown: 'var(--kcg-ship-unknown)', }; // Ship type color legend (MarineTraffic style) const SHIP_TYPE_LEGEND: [string, string][] = [ ['cargo', 'var(--kcg-ship-cargo)'], ['tanker', 'var(--kcg-ship-tanker)'], ['passenger', 'var(--kcg-ship-passenger)'], ['fishing', 'var(--kcg-ship-fishing)'], ['fishing_gear', '#f97316'], ['pleasure', 'var(--kcg-ship-pleasure)'], ['military', 'var(--kcg-ship-military)'], ['tug_special', 'var(--kcg-ship-tug)'], ['other', 'var(--kcg-ship-other)'], ['unspecified', 'var(--kcg-ship-unknown)'], ]; const AC_CATEGORIES = ['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian', 'unknown'] as const; const MT_CATEGORIES = ['cargo', 'tanker', 'passenger', 'fishing', 'fishing_gear', 'military', 'tug_special', 'high_speed', 'pleasure', 'other', 'unspecified'] as const; // Nationality categories for Korea tab const NAT_CATEGORIES = ['KR', 'CN', 'KP', 'JP', 'unclassified'] as const; // Fishing vessel nationality categories const FISHING_NAT_CATEGORIES = ['CN', 'KR', 'JP', 'other'] as const; const FISHING_NAT_LABELS: Record = { CN: 'πŸ‡¨πŸ‡³ 쀑ꡭ어선', KR: 'πŸ‡°πŸ‡· ν•œκ΅­μ–΄μ„ ', JP: 'πŸ‡―πŸ‡΅ 일본어선', other: '🏳️ 기타어선', }; const FISHING_NAT_COLORS: Record = { CN: '#ef4444', KR: '#3b82f6', JP: '#f472b6', other: '#6b7280', }; const NAT_LABELS: Record = { KR: 'πŸ‡°πŸ‡· ν•œκ΅­', CN: 'πŸ‡¨πŸ‡³ 쀑ꡭ', KP: 'πŸ‡°πŸ‡΅ λΆν•œ', JP: 'πŸ‡―πŸ‡΅ 일본', unclassified: '🏳️ λ―ΈλΆ„λ₯˜', }; const NAT_COLORS: Record = { KR: '#3b82f6', CN: '#ef4444', KP: '#f97316', JP: '#f472b6', 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; color: string; count?: number; group?: string; } const GROUP_META: Record = { '항곡망': { label: '항곡망', color: '#22d3ee' }, 'ν•΄μ–‘μ•ˆμ „': { label: 'ν•΄μ–‘μ•ˆμ „', color: '#3b82f6' }, 'ꡭ가기관망': { label: 'ꡭ가기관망', color: '#f59e0b' }, 'μœ„ν—˜μ‹œμ„€': { label: 'μœ„ν—˜μ‹œμ„€', color: '#ef4444', superGroup: 'μœ„ν—˜/μ‚°μ—… 인프라' }, 'μ—λ„ˆμ§€/λ°œμ „μ‹œμ„€': { label: 'μ—λ„ˆμ§€/λ°œμ „μ‹œμ„€', color: '#a855f7', superGroup: 'μœ„ν—˜/μ‚°μ—… 인프라' }, '산업곡정/μ œμ‘°μ‹œμ„€': { label: '산업곡정/μ œμ‘°μ‹œμ„€', color: '#0ea5e9', superGroup: 'μœ„ν—˜/μ‚°μ—… 인프라' }, }; const SUPER_GROUP_META: Record = { 'μœ„ν—˜/μ‚°μ—… 인프라': { label: 'μœ„ν—˜/μ‚°μ—… 인프라', color: '#f97316' }, }; interface OverseasItem { key: string; label: string; color: string; count?: number; children?: OverseasItem[]; } function countOverseasActiveLeaves(items: OverseasItem[], layers: Record): number { let count = 0; for (const item of items) { if (item.children?.length) { count += countOverseasActiveLeaves(item.children, layers); } else if (layers[item.key]) { count += (item.count ?? 1); } } return count; } // ── Tree helpers ────────────────────────────────────── function getAllLeafKeys(node: LayerTreeNode): string[] { if (!node.children) return [node.key]; return node.children.flatMap(getAllLeafKeys); } function isNodeActive(node: LayerTreeNode, 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; hiddenShipCategories: Set; onAcCategoryToggle: (cat: string) => void; onShipCategoryToggle: (cat: string) => void; shipsByNationality?: Record; hiddenNationalities?: Set; onNationalityToggle?: (nat: string) => void; fishingByNationality?: Record; hiddenFishingNats?: Set; 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, 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>(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 (

LAYERS

{tree ? ( // ── Unified tree rendering ── tree.map(node => ( )) ) : ( // ── Legacy rendering (backward compat) ── <> {/* Ships tree */} onToggle('ships')} onExpand={() => toggleExpand('ships')} /> {layers.ships && expanded.has('ships') && ( )} {/* Nationality tree (Korea tab only) */} {shipsByNationality && hiddenNationalities && onNationalityToggle && ( <> a + b, 0)} color="#8b5cf6" active expandable isExpanded={expanded.has('nationality')} onToggle={() => toggleExpand('nationality')} onExpand={() => toggleExpand('nationality')} /> {expanded.has('nationality') && ( )} )} {/* 항곡망 κ·Έλ£Ή */} toggleExpand('group-항곡망')} onExpand={() => toggleExpand('group-항곡망')} /> {expanded.has('group-항곡망') && (
onToggle('aircraft')} onExpand={() => toggleExpand('aircraft')} /> {layers.aircraft && expanded.has('aircraft') && ( )} 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 = {}; 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}`)} /> {isGroupExpanded && (
{items.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 => ( ))}
)} )}
); } /* ── Overseas 3-level tree node ────────────────────── */ function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll, toggleExpand }: { item: OverseasItem; depth: number; layers: Record; expanded: Set; onToggle: (key: string) => void; onToggleAll: (key: string) => void; toggleExpand: (key: string) => void; }) { const hasChildren = item.children && item.children.length > 0; const isExpanded = expanded.has(item.key); const isActive = hasChildren ? item.children!.some(c => c.children?.length ? c.children.some(gc => layers[gc.key]) : layers[c.key]) : layers[item.key]; const leafCount = hasChildren ? countOverseasActiveLeaves([item], layers) : (item.count ?? 0); const handleToggle = () => { if (hasChildren) { const allLeaves: string[] = []; const collectLeaves = (node: OverseasItem) => { if (node.children?.length) node.children.forEach(collectLeaves); else allLeaves.push(node.key); }; collectLeaves(item); const allOn = allLeaves.every(k => layers[k]); for (const k of allLeaves) { if (allOn || !layers[k]) onToggleAll(k); } } else { onToggle(item.key); } }; return (
toggleExpand(item.key)} /> {hasChildren && isExpanded && (
{item.children!.map(child => ( ))}
)}
); } /* ── Sub-components ─────────────────────────────────── */ function LayerTreeItem({ layerKey, label, color, active, expandable, isExpanded, count, onToggle, onExpand, }: { layerKey: string; label: string; color: string; active: boolean; expandable?: boolean; isExpanded?: boolean; count?: number; onToggle: () => void; onExpand?: () => void; }) { return (
{expandable ? ( { e.stopPropagation(); onExpand?.(); }} > {'\u25B6'} ) : ( )}
); } function CategoryToggle({ label, color, count, hidden, onClick, }: { label: string; color: string; count: number; hidden: boolean; onClick: () => void; }) { return (
{label} {count}
); }