- 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>
271 lines
9.5 KiB
TypeScript
Executable File
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>
|
|
)
|
|
}
|