kcg-monitoring/frontend/src/components/LayerPanel.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

378 lines
12 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
// 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)',
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)'],
['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', 'military', 'tug_special', 'high_speed', 'pleasure', 'other', 'unspecified'] as const;
interface ExtraLayer {
key: string;
label: string;
color: string;
count?: number;
}
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[];
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
onAcCategoryToggle: (cat: string) => void;
onShipCategoryToggle: (cat: string) => void;
}
export function LayerPanel({
layers,
onToggle,
aircraftByCategory,
aircraftTotal,
shipsByMtCategory,
shipTotal,
satelliteCount,
extraLayers,
hiddenAcCategories,
hiddenShipCategories,
onAcCategoryToggle,
onShipCategoryToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useState<Set<string>>(new Set(['aircraft', '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;
});
}, []);
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);
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
{/* Aircraft tree */}
<LayerTreeItem
layerKey="aircraft"
label={`${t('layers.aircraft')} (${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)}
/>
);
})}
{/* Altitude legend */}
<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>
)}
{/* Military legend */}
<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>
)}
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={`${t('layers.ships')} (${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;
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 */}
<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>
)}
{/* Satellites (simple toggle) */}
<LayerTreeItem
layerKey="satellites"
label={`${t('layers.satellites')} (${satelliteCount})`}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
/>
{/* Extra layers (tab-specific) */}
{extraLayers && extraLayers.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
<div className="layer-divider" />
{/* Military only filter */}
<LayerTreeItem
layerKey="militaryOnly"
label={`${t('layers.militaryOnly')} (${militaryCount})`}
color="#f97316"
active={layers.militaryOnly ?? false}
onToggle={() => onToggle('militaryOnly')}
/>
</div>
</div>
);
}
/* ── Sub-components ─────────────────────────────────── */
function LayerTreeItem({
layerKey,
label,
color,
active,
expandable,
isExpanded,
onToggle,
onExpand,
}: {
layerKey: string;
label: string;
color: string;
active: boolean;
expandable?: boolean;
isExpanded?: boolean;
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' }}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
</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>
);
}