736 lines
26 KiB
TypeScript
736 lines
26 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
|
|
|
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
|
const AC_CAT_COLORS: Record<string, string> = {
|
|
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<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',
|
|
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<string, string> = {
|
|
CN: '🇨🇳 중국어선',
|
|
KR: '🇰🇷 한국어선',
|
|
JP: '🇯🇵 일본어선',
|
|
other: '🏳️ 기타어선',
|
|
};
|
|
const FISHING_NAT_COLORS: Record<string, string> = {
|
|
CN: '#ef4444',
|
|
KR: '#3b82f6',
|
|
JP: '#f472b6',
|
|
other: '#6b7280',
|
|
};
|
|
const NAT_LABELS: Record<string, string> = {
|
|
KR: '🇰🇷 한국',
|
|
CN: '🇨🇳 중국',
|
|
KP: '🇰🇵 북한',
|
|
JP: '🇯🇵 일본',
|
|
unclassified: '🏳️ 미분류',
|
|
};
|
|
const NAT_COLORS: Record<string, string> = {
|
|
KR: '#3b82f6',
|
|
CN: '#ef4444',
|
|
KP: '#f97316',
|
|
JP: '#f472b6',
|
|
unclassified: '#6b7280',
|
|
};
|
|
|
|
interface ExtraLayer {
|
|
key: string;
|
|
label: string;
|
|
color: string;
|
|
count?: number;
|
|
group?: string;
|
|
}
|
|
|
|
const GROUP_META: Record<string, { label: string; color: string; superGroup?: string }> = {
|
|
'항공망': { 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<string, { label: string; color: string }> = {
|
|
'위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' },
|
|
};
|
|
|
|
interface OverseasItem {
|
|
key: string;
|
|
label: string;
|
|
color: string;
|
|
count?: number;
|
|
children?: OverseasItem[];
|
|
}
|
|
|
|
function countOverseasActiveLeaves(items: OverseasItem[], layers: Record<string, boolean>): 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;
|
|
}
|
|
|
|
interface LayerPanelProps {
|
|
layers: Record<string, boolean>;
|
|
onToggle: (key: string) => void;
|
|
aircraftByCategory: Record<string, number>;
|
|
aircraftTotal: number;
|
|
shipsByMtCategory: Record<string, number>;
|
|
shipTotal: number;
|
|
satelliteCount: number;
|
|
extraLayers?: ExtraLayer[];
|
|
overseasItems?: OverseasItem[];
|
|
hiddenAcCategories: Set<string>;
|
|
hiddenShipCategories: Set<string>;
|
|
onAcCategoryToggle: (cat: string) => void;
|
|
onShipCategoryToggle: (cat: string) => void;
|
|
shipsByNationality?: Record<string, number>;
|
|
hiddenNationalities?: Set<string>;
|
|
onNationalityToggle?: (nat: string) => void;
|
|
fishingByNationality?: Record<string, number>;
|
|
hiddenFishingNats?: Set<string>;
|
|
onFishingNatToggle?: (nat: string) => void;
|
|
}
|
|
|
|
export function LayerPanel({
|
|
layers,
|
|
onToggle,
|
|
aircraftByCategory,
|
|
aircraftTotal,
|
|
shipsByMtCategory,
|
|
shipTotal,
|
|
satelliteCount,
|
|
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; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
|
|
|
|
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 (
|
|
<div key={cat}>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<span
|
|
style={{ fontSize: 7, color: 'var(--kcg-dim)', width: 10, textAlign: 'center', cursor: 'pointer', flexShrink: 0 }}
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand('fishing-sub'); }}
|
|
>
|
|
{isFishingExpanded ? '▼' : '▶'}
|
|
</span>
|
|
<div style={{ flex: 1 }}>
|
|
<CategoryToggle
|
|
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
|
|
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
|
|
count={count}
|
|
hidden={hiddenShipCategories.has(cat)}
|
|
onClick={() => onShipCategoryToggle(cat)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isFishingExpanded && !hiddenShipCategories.has('fishing') && (
|
|
<div style={{ paddingLeft: 14 }}>
|
|
{FISHING_NAT_CATEGORIES.map(nat => {
|
|
const fCount = fishingByNationality[nat] || 0;
|
|
if (fCount === 0) return null;
|
|
return (
|
|
<CategoryToggle
|
|
key={`fishing-${nat}`}
|
|
label={FISHING_NAT_LABELS[nat] || nat}
|
|
color={FISHING_NAT_COLORS[nat] || '#888'}
|
|
count={fCount}
|
|
hidden={hiddenFishingNats.has(nat)}
|
|
onClick={() => onFishingNatToggle(nat)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CategoryToggle
|
|
key={cat}
|
|
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
|
|
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
|
|
count={count}
|
|
hidden={hiddenShipCategories.has(cat)}
|
|
onClick={() => onShipCategoryToggle(cat)}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Ship type legend (Korea tab only) */}
|
|
{shipsByNationality && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="legend-toggle"
|
|
onClick={() => toggleLegend('shipType')}
|
|
>
|
|
{legendOpen.has('shipType') ? '\u25BC' : '\u25B6'} {t('legend.vesselType')}
|
|
</button>
|
|
{legendOpen.has('shipType') && (
|
|
<div className="legend-content">
|
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
|
{SHIP_TYPE_LEGEND.map(([key, color]) => (
|
|
<div key={key} className="flex items-center gap-1.5">
|
|
<span
|
|
className="shrink-0"
|
|
style={{
|
|
width: 0, height: 0,
|
|
borderLeft: '5px solid transparent',
|
|
borderRight: '5px solid transparent',
|
|
borderBottom: `10px solid ${color}`,
|
|
}}
|
|
/>
|
|
<span className="text-kcg-text">{t(`ships:mtType.${key}`, key)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</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') && (
|
|
<div className="layer-tree-children">
|
|
{AC_CATEGORIES.map(cat => {
|
|
const count = aircraftByCategory[cat] || 0;
|
|
if (count === 0) return null;
|
|
return (
|
|
<CategoryToggle
|
|
key={cat}
|
|
label={t(`ships:aircraft.${cat}`, cat.toUpperCase())}
|
|
color={AC_CAT_COLORS[cat] || '#888'}
|
|
count={count}
|
|
hidden={hiddenAcCategories.has(cat)}
|
|
onClick={() => onAcCategoryToggle(cat)}
|
|
/>
|
|
);
|
|
})}
|
|
<button type="button" className="legend-toggle" onClick={() => toggleLegend('altitude')}>
|
|
{legendOpen.has('altitude') ? '\u25BC' : '\u25B6'} {t('legend.altitude')}
|
|
</button>
|
|
{legendOpen.has('altitude') && (
|
|
<div className="legend-content">
|
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
|
{ALT_LEGEND.map(([label, color]) => (
|
|
<div key={label} className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm" style={{ background: color }} />
|
|
<span className="text-kcg-text">{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button type="button" className="legend-toggle" onClick={() => toggleLegend('military')}>
|
|
{legendOpen.has('military') ? '\u25BC' : '\u25B6'} {t('legend.military')}
|
|
</button>
|
|
{legendOpen.has('military') && (
|
|
<div className="legend-content">
|
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
|
{MIL_LEGEND.map(([label, color]) => (
|
|
<div key={label} className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm" style={{ background: color }} />
|
|
<span className="text-kcg-text">{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Satellites */}
|
|
<LayerTreeItem
|
|
layerKey="satellites"
|
|
label={t('layers.satellites')}
|
|
count={satelliteCount}
|
|
color="#ef4444"
|
|
active={layers.satellites}
|
|
onToggle={() => onToggle('satellites')}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Extra layers — grouped */}
|
|
{extraLayers && (() => {
|
|
const grouped: Record<string, ExtraLayer[]> = {};
|
|
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<string, string[]> = {}; // 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 (
|
|
<div key={groupName} style={indent ? { paddingLeft: 10 } : undefined}>
|
|
<LayerTreeItem
|
|
layerKey={`group-${groupName}`}
|
|
label={meta.label}
|
|
color={meta.color}
|
|
active
|
|
expandable
|
|
isExpanded={isGroupExpanded}
|
|
onToggle={() => toggleExpand(`group-${groupName}`)}
|
|
onExpand={() => toggleExpand(`group-${groupName}`)}
|
|
/>
|
|
{isGroupExpanded && (
|
|
<div className="layer-tree-children">
|
|
{items.map(el => (
|
|
<LayerTreeItem
|
|
key={el.key}
|
|
layerKey={el.key}
|
|
label={el.label}
|
|
count={el.count}
|
|
color={el.color}
|
|
active={layers[el.key] ?? false}
|
|
onToggle={() => onToggle(el.key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div key={sgName}>
|
|
<LayerTreeItem
|
|
layerKey={`supergroup-${sgName}`}
|
|
label={sgMeta.label}
|
|
color={sgMeta.color}
|
|
active
|
|
expandable
|
|
isExpanded={isSgExpanded}
|
|
onToggle={() => toggleExpand(`supergroup-${sgName}`)}
|
|
onExpand={() => toggleExpand(`supergroup-${sgName}`)}
|
|
/>
|
|
{isSgExpanded && (
|
|
<div className="layer-tree-children">
|
|
{groupNames.map(g => renderGroup(g, true))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 그룹 없는 개별 레이어 */}
|
|
{ungrouped.map(el => (
|
|
<LayerTreeItem
|
|
key={el.key}
|
|
layerKey={el.key}
|
|
label={el.label}
|
|
count={el.count}
|
|
color={el.color}
|
|
active={layers[el.key] ?? false}
|
|
onToggle={() => onToggle(el.key)}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
<div className="layer-divider" />
|
|
|
|
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
|
|
<LayerTreeItem
|
|
layerKey="overseas-section"
|
|
label="해외시설"
|
|
count={overseasItems ? countOverseasActiveLeaves(overseasItems, layers) : 0}
|
|
color="#f97316"
|
|
active={expanded.has('overseas-section')}
|
|
expandable
|
|
isExpanded={expanded.has('overseas-section')}
|
|
onToggle={() => toggleExpand('overseas-section')}
|
|
onExpand={() => toggleExpand('overseas-section')}
|
|
/>
|
|
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
|
|
<div className="layer-tree-children">
|
|
{overseasItems.map(item => (
|
|
<OverseasTreeNode
|
|
key={item.key}
|
|
item={item}
|
|
depth={0}
|
|
layers={layers}
|
|
expanded={expanded}
|
|
onToggle={onToggle}
|
|
onToggleAll={onToggle}
|
|
toggleExpand={toggleExpand}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Overseas 3-level tree node ────────────────────── */
|
|
|
|
function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll, toggleExpand }: {
|
|
item: OverseasItem;
|
|
depth: number;
|
|
layers: Record<string, boolean>;
|
|
expanded: Set<string>;
|
|
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) {
|
|
// 부모 토글 → 모든 하위 리프 on/off
|
|
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 (
|
|
<div style={{ marginLeft: depth * 12 }}>
|
|
<LayerTreeItem
|
|
layerKey={item.key}
|
|
label={item.label}
|
|
color={item.color}
|
|
active={!!isActive}
|
|
expandable={!!hasChildren}
|
|
isExpanded={isExpanded}
|
|
count={leafCount}
|
|
onToggle={handleToggle}
|
|
onExpand={() => toggleExpand(item.key)}
|
|
/>
|
|
{hasChildren && isExpanded && (
|
|
<div className="layer-tree-children">
|
|
{item.children!.map(child => (
|
|
<OverseasTreeNode
|
|
key={child.key}
|
|
item={child}
|
|
depth={depth + 1}
|
|
layers={layers}
|
|
expanded={expanded}
|
|
onToggle={onToggle}
|
|
onToggleAll={onToggleAll}
|
|
toggleExpand={toggleExpand}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── 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 (
|
|
<div className="layer-tree-header" data-layer={layerKey}>
|
|
{expandable ? (
|
|
<span
|
|
className={`layer-tree-arrow ${isExpanded ? 'expanded' : ''}`}
|
|
onClick={e => { e.stopPropagation(); onExpand?.(); }}
|
|
>
|
|
{'\u25B6'}
|
|
</span>
|
|
) : (
|
|
<span className="layer-tree-arrow" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`layer-toggle ${active ? 'active' : ''}`}
|
|
onClick={onToggle}
|
|
style={{ padding: 0, gap: '6px', flex: 1, width: '100%' }}
|
|
>
|
|
<span
|
|
className="layer-dot"
|
|
style={{ backgroundColor: active ? color : '#444' }}
|
|
/>
|
|
<span style={{ flex: 1 }}>{label}</span>
|
|
{count != null && (
|
|
<span style={{ fontSize: 9, color: 'var(--kcg-dim)', flexShrink: 0 }}>{count}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryToggle({
|
|
label,
|
|
color,
|
|
count,
|
|
hidden,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
color: string;
|
|
count: number;
|
|
hidden: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`category-toggle ${hidden ? 'hidden' : ''}`}
|
|
onClick={onClick}
|
|
>
|
|
<span className="category-dot" style={{ backgroundColor: color }} />
|
|
<span className="category-label">{label}</span>
|
|
<span className="category-count">{count}</span>
|
|
</div>
|
|
);
|
|
}
|