kcg-monitoring/frontend/src/components/common/LayerPanel.tsx
htlee 44aa449b03 feat: 지도 글꼴 크기 커스텀 시스템 (4개 그룹 슬라이더)
- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위
- LAYERS 패널 하단 슬라이더 UI, localStorage 영속화
- Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용
- MapLibre 선박 라벨/국가명 실시간 반영
- 모든 useMemo deps + updateTriggers에 fontScale 포함
2026-03-24 09:27:11 +09:00

1050 lines
35 KiB
TypeScript

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<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',
};
// ── 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<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;
}
// ── 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>;
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;
}
// ── 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,
hiddenShipCategories,
onShipCategoryToggle,
fishingByNationality,
hiddenFishingNats,
onFishingNatToggle,
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']);
return (
<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)}
/>
);
})}
{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>
);
}
// ── 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;
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>
);
}
// ── 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}
/>
)}
<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[]> = {};
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>
<FontScalePanel />
</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) {
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>
);
}