wing-ops/frontend/src/common/components/layer/LayerTree.tsx
htlee a61864646f refactor(frontend): 공통 모듈 common/ 분리 + OpenLayers 제거 + path alias 설정
- OpenLayers(ol) 패키지 제거 (미사용, import 0건)
- common/ 디렉토리 생성: components, hooks, services, store, types, utils
- 17개 공통 파일을 common/으로 이동 (git mv, blame 이력 보존)
- MainTab 타입을 App.tsx에서 common/types/navigation.ts로 분리
- tsconfig path alias (@common/*, @tabs/*) + vite resolve.alias 설정
- 42개 import 경로를 @common/ alias 또는 상대경로로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:00:50 +09:00

271 lines
9.5 KiB
TypeScript
Executable File

import { useState, useRef, useEffect } from 'react'
import type { Layer } from '../../../data/layerDatabase'
const PRESET_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
'#3b82f6','#8b5cf6','#a855f7','#ec4899','#f43f5e',
'#64748b','#ffffff',
]
interface LayerTreeProps {
layers: (Layer & { children?: Layer[] })[]
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
layerColors?: Record<string, string>
onColorChange?: (layerId: string, color: string) => void
}
export function LayerTree({ layers, enabledLayers, onToggleLayer, layerColors = {}, onColorChange }: LayerTreeProps) {
const allLeafIds = getAllLeafIds(layers)
const allEnabled = allLeafIds.length > 0 && allLeafIds.every(id => enabledLayers.has(id))
const handleToggleAll = () => {
const newState = !allEnabled
getAllNodeIds(layers).forEach(id => onToggleLayer(id, newState))
}
return (
<div style={{ padding: '0 4px' }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '4px 8px 8px', marginBottom: '4px',
borderBottom: '1px solid var(--bd)',
}}>
<span style={{ fontSize: '10px', fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
</span>
<div
className={`lyr-sw ${allEnabled ? 'on' : ''}`}
onClick={handleToggleAll}
style={{ cursor: 'pointer' }}
/>
</div>
{layers.map(layer => (
<LayerNode
key={layer.id}
layer={layer}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onColorChange}
depth={0}
/>
))}
</div>
)
}
function getAllLeafIds(layers: (Layer & { children?: Layer[] })[]): string[] {
const ids: string[] = []
for (const l of layers) {
if (l.children && l.children.length > 0) {
ids.push(...getAllLeafIds(l.children))
} else {
ids.push(l.id)
}
}
return ids
}
function getAllNodeIds(layers: (Layer & { children?: Layer[] })[]): string[] {
const ids: string[] = []
for (const l of layers) {
ids.push(l.id)
if (l.children && l.children.length > 0) {
ids.push(...getAllNodeIds(l.children))
}
}
return ids
}
interface LayerNodeProps {
layer: Layer & { children?: Layer[] }
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
layerColors: Record<string, string>
onColorChange?: (layerId: string, color: string) => void
depth: number
}
function LayerNode({ layer, enabledLayers, onToggleLayer, layerColors, onColorChange, depth }: LayerNodeProps) {
const [expanded, setExpanded] = useState(depth < 1)
const hasChildren = layer.children && layer.children.length > 0
const isEnabled = enabledLayers.has(layer.id)
const getAllDescendantIds = (node: Layer & { children?: Layer[] }): string[] => {
const ids: string[] = []
if (node.children) {
for (const child of node.children) {
ids.push(child.id)
ids.push(...getAllDescendantIds(child))
}
}
return ids
}
const handleSwitchClick = (e: React.MouseEvent) => {
e.stopPropagation()
const newState = !isEnabled
onToggleLayer(layer.id, newState)
if (hasChildren) {
getAllDescendantIds(layer).forEach(id => onToggleLayer(id, newState))
}
}
const handleHeaderClick = () => {
if (hasChildren) setExpanded(!expanded)
}
// depth 0 — 대분류 (lyr-g1 / lyr-h1)
if (depth === 0) {
return (
<div className="lyr-g1">
<div className="lyr-h1" onClick={handleHeaderClick}>
{hasChildren ? (
<span className={`lyr-arr ${expanded ? 'open' : ''}`}></span>
) : (
<span className="lyr-arr" style={{ visibility: 'hidden' }}></span>
)}
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
{layer.icon && <span>{layer.icon}</span>}
{layer.name}
{layer.count !== undefined && (
<span className="lyr-h1-cnt">{layer.count.toLocaleString()}</span>
)}
</div>
{hasChildren && (
<div className={`lyr-c1 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '800px' : '0' }}>
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
// depth 1 — 중분류 (lyr-g2 / lyr-h2) 또는 leaf
if (depth === 1) {
// 자식 없는 depth 1 → leaf로 렌더링 (해양관측·기상 하위 등)
if (!hasChildren) {
return (
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
{layer.icon && <span>{layer.icon}</span>}
<span style={{ flex: 1 }}>{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
{onColorChange && (
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
)}
</div>
)
}
return (
<div className="lyr-g2">
<div className="lyr-h2" onClick={handleHeaderClick}>
{hasChildren ? (
<span className={`lyr-arr ${expanded ? 'open' : ''}`}></span>
) : (
<span className="lyr-arr" style={{ visibility: 'hidden' }}></span>
)}
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
{layer.icon && <span>{layer.icon}</span>}
{layer.name}
{layer.count !== undefined && (
<span className="lyr-h2-cnt">{layer.count.toLocaleString()}</span>
)}
</div>
<div className={`lyr-c2 ${expanded ? '' : 'collapsed'}`} style={{ maxHeight: expanded ? '600px' : '0' }}>
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
))}
</div>
</div>
)
}
// depth 2+ leaf — 색상 스와치 포함
if (!hasChildren) {
return (
<div className="lyr-t" onClick={(e) => { if (!(e.target as HTMLElement).closest('.lyr-csw, .lyr-cpop')) handleSwitchClick(e) }}>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} />
{layer.icon && <span>{layer.icon}</span>}
<span style={{ flex: 1 }}>{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
{onColorChange && (
<ColorSwatch color={layerColors[layer.id]} onChange={(c) => onColorChange(layer.id, c)} />
)}
</div>
)
}
// depth 2+ with children
return (
<div>
<div className="lyr-t" style={{ gap: '6px' }}>
<span className={`lyr-arr ${expanded ? 'open' : ''}`} onClick={handleHeaderClick} style={{ cursor: 'pointer', fontSize: '7px', width: '10px', textAlign: 'center' }}></span>
<div className={`lyr-sw ${isEnabled ? 'on' : ''}`} onClick={handleSwitchClick} />
{layer.icon && <span>{layer.icon}</span>}
<span onClick={handleHeaderClick} style={{ cursor: 'pointer', flex: 1 }}>{layer.name}</span>
{layer.count !== undefined && <span className="lyr-cnt">{layer.count.toLocaleString()}</span>}
</div>
{expanded && (
<div style={{ paddingLeft: '16px' }}>
{layer.children!.map(child => (
<LayerNode key={child.id} layer={child} enabledLayers={enabledLayers} onToggleLayer={onToggleLayer} layerColors={layerColors} onColorChange={onColorChange} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
// 색상 스와치 + 피커
function ColorSwatch({ color, onChange }: { color?: string; onChange: (c: string) => void }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<div
className={`lyr-csw ${color ? 'has-color' : ''}`}
style={color ? { borderColor: color, background: color } : {}}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
/>
{open && (
<div className="lyr-cpop show" onClick={(e) => e.stopPropagation()}>
<div className="lyr-cpr">
{PRESET_COLORS.map(pc => (
<div
key={pc}
className={`lyr-cpr-i ${color === pc ? 'sel' : ''}`}
style={{ background: pc }}
onClick={() => { onChange(pc); setOpen(false) }}
/>
))}
</div>
<div className="lyr-ccustom">
<label></label>
<input
type="color"
value={color || '#06b6d4'}
onChange={(e) => { onChange(e.target.value); setOpen(false) }}
/>
</div>
</div>
)}
</div>
)
}