Files
web-cad/frontend/src/components/LayerPanel.tsx
T

296 lines
16 KiB
TypeScript

import React from 'react';
import type { LayerPanelProps } from '../types/ui.types';
import type { CADLayer, CADElement } from '../types/cad.types';
import type { TreeNode } from '../types/ui.types';
import TreeView from './TreeView';
const elementTypeLabels: Record<string, string> = {
line: 'Linie',
rect: 'Rechteck',
circle: 'Kreis',
arc: 'Bogen',
polyline: 'Polylinie',
polygon: 'Polygon',
text: 'Text',
dimension: 'Bemassung',
leader: 'Hinweislinie',
revcloud: 'Revisionswolke',
hatch: 'Schraffur',
chair: 'Stuhl',
'seating-row': 'Reihe',
'seating-block': 'Block',
table: 'Tisch',
stage: 'Buhne',
'seating-template': 'Vorlage',
};
const layerIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12,2 22,8 12,14 2,8" />
<polyline points="2,16 12,22 22,16" />
<polyline points="2,12 12,18 22,12" />
</svg>
);
const groupIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<rect x="7" y="11" width="4" height="4" rx="0.5" />
<rect x="13" y="11" width="4" height="4" rx="0.5" />
<line x1="9" y1="15" x2="9" y2="17" />
<line x1="15" y1="15" x2="15" y2="17" />
<line x1="9" y1="17" x2="15" y2="17" />
</svg>
);
const elementIcons: Record<string, React.ReactNode> = {
line: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="20" x2="20" y2="4"/></svg>,
rect: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/></svg>,
circle: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>,
arc: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 20 A16 16 0 0 1 20 4"/></svg>,
polyline: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="4,18 8,8 14,14 20,6"/></svg>,
polygon: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12,4 20,10 16,20 8,20 4,10"/></svg>,
text: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>,
dimension: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 L20 16 M4 16 L4 12 M20 16 L20 12 M8 16 L8 14 M16 16 L16 14"/></svg>,
leader: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4 L12 12 L12 20"/><circle cx="4" cy="4" r="2"/></svg>,
revcloud: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 Q6 12 10 14 Q12 10 16 14 Q20 12 20 16 Q20 20 16 20 L8 20 Q4 20 4 16Z"/></svg>,
hatch: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="16" x2="20" y2="16"/></svg>,
chair: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 4v8h12V4 M6 12v8 M18 12v8 M4 12h16"/></svg>,
'seating-row': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="8" width="4" height="8"/><rect x="8" y="8" width="4" height="8"/><rect x="14" y="8" width="4" height="8"/><rect x="20" y="8" width="2" height="8"/></svg>,
'seating-block': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="4" width="6" height="6"/><rect x="10" y="4" width="6" height="6"/><rect x="2" y="12" width="6" height="6"/><rect x="10" y="12" width="6" height="6"/></svg>,
table: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="6" width="16" height="12"/><line x1="4" y1="10" x2="20" y2="10"/><line x1="4" y1="14" x2="20" y2="14"/></svg>,
stage: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 18 L12 6 L20 18Z"/></svg>,
'seating-template': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/><circle cx="16" cy="8" r="1"/><circle cx="8" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="16" cy="12" r="1"/></svg>,
};
const defaultIcon = <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>;
const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
elements = [],
activeLayerId,
selectedElementIds = [],
groups = [],
onSelectLayer,
onSelectElement,
onAddLayer,
onToggleLayer,
onDeleteLayer,
onRenameLayer,
onDuplicateLayer,
onToggleLock,
onReorder,
onAddSubLayer,
onElementsDeleted,
onToggleElementVisible,
}) => {
const buildTree = (parentId: string | null): TreeNode[] => {
return layers
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((l) => {
const layerElements = elements.filter((e) => e.layerId === l.id);
// Build group nodes for this layer
const layerGroups = groups.filter((g) =>
g.elementIds.some((eid) => layerElements.some((e) => e.id === eid))
);
const groupNodes: TreeNode[] = layerGroups.map((g) => {
const groupElementIds = g.elementIds.filter((eid) =>
layerElements.some((e) => e.id === eid)
);
const groupElementsList = groupElementIds
.map((eid) => elements.find((e) => e.id === eid))
.filter((e): e is CADElement => e !== undefined);
const groupChildNodes: TreeNode[] = groupElementsList.map((e) => ({
id: e.id,
name: elementTypeLabels[e.type] || e.type,
icon: elementIcons[e.type] || defaultIcon,
expanded: false,
active: selectedElementIds.includes(e.id),
children: [],
}));
// A group is "active" if any of its elements are selected
const groupActive = groupElementIds.some((eid) => selectedElementIds.includes(eid));
return {
id: g.id,
name: g.name,
icon: groupIcon,
expanded: false,
active: groupActive,
children: groupChildNodes,
count: groupElementIds.length > 0 ? groupElementIds.length : undefined,
} as TreeNode;
});
// Build element nodes — only elements NOT in any group go directly under layer
const groupedElementIds = new Set<string>();
for (const g of layerGroups) {
for (const eid of g.elementIds) {
if (layerElements.some((e) => e.id === eid)) {
groupedElementIds.add(eid);
}
}
}
const ungroupedElements = layerElements.filter((e) => !groupedElementIds.has(e.id));
const elementNodes: TreeNode[] = ungroupedElements.map((e) => ({
id: e.id,
name: elementTypeLabels[e.type] || e.type,
icon: elementIcons[e.type] || defaultIcon,
expanded: false,
active: selectedElementIds.includes(e.id),
children: [],
}));
const subLayerNodes = buildTree(l.id);
// Groups appear above elements, sublayers appear first
return {
id: l.id,
name: l.name,
icon: layerIcon,
expanded: false,
active: l.id === activeLayerId,
children: [...subLayerNodes, ...groupNodes, ...elementNodes],
count: layerElements.length > 0 ? layerElements.length : undefined,
} as TreeNode;
});
};
const tree = buildTree(null);
// Determine which IDs are layer IDs vs element IDs vs group IDs for dispatch
const layerIds = new Set(layers.map((l) => l.id));
const groupIds = new Set(groups.map((g) => g.id));
const handleSelect = (id: string) => {
if (layerIds.has(id)) {
onSelectLayer(id);
} else if (groupIds.has(id)) {
const group = groups.find((g) => g.id === id);
if (group && group.elementIds.length > 0) {
onSelectElement?.(group.elementIds[0]);
}
} else {
// It's an element ID
onSelectElement?.(id);
}
};
const handleAddSubLayer = (parentId: string) => {
if (onAddSubLayer) {
onAddSubLayer(parentId);
}
};
return (
<div className="layer-panel" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #333)' }}>
<span style={{ fontWeight: 600, fontSize: '13px' }}>Ebenen</span>
<button
className="add-layer-btn"
onClick={onAddLayer}
title="Neue Ebene"
aria-label="Neue Ebene"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-color, #ccc)', padding: '4px' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
{tree.length === 0 ? (
<div style={{ padding: '12px', textAlign: 'center', color: 'var(--text-muted, #888)', fontSize: '12px' }}>
Keine Ebenen vorhanden
</div>
) : (
<TreeView
nodes={tree}
selectedId={activeLayerId}
selectedIds={selectedElementIds}
onSelect={handleSelect}
onReorder={onReorder}
draggable={true}
renderIcon={(node) => node.icon}
renderActions={(node) => {
const layer = layers.find((l) => l.id === node.id);
if (layer) {
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleLayer(layer.id); }}
title={layer.visible ? 'Sichtbar' : 'Versteckt'}
aria-label={layer.visible ? 'Verstecken' : 'Anzeigen'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.visible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)', display: 'flex', alignItems: 'center' }}
>
{layer.visible ? <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> : <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>}
</button>
<button
onClick={(e) => { e.stopPropagation(); onToggleLock?.(layer.id); }}
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.locked ? 'var(--accent, #f59e0b)' : 'var(--text-muted, #666)', display: 'flex', alignItems: 'center' }}
>
{layer.locked ? <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> : <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddSubLayer(layer.id); }}
title="Teilebene hinzufugen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicateLayer?.(layer.id); }}
title="Duplizieren"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)', display: 'flex', alignItems: 'center' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteLayer?.(layer.id); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336', display: 'flex', alignItems: 'center' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
);
}
const element = elements.find((el) => el.id === node.id);
if (element) {
const isVisible = element.properties?.visible !== false;
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleElementVisible?.(element.id); }}
title={isVisible ? 'Sichtbar' : 'Versteckt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: isVisible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)', display: 'flex', alignItems: 'center' }}
>
{isVisible ? <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> : <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>}
</button>
<button
onClick={(e) => { e.stopPropagation(); onElementsDeleted?.([element.id]); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336', display: 'flex', alignItems: 'center' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
);
}
return null;
}}
/>
)}
</div>
</div>
);
};
export default LayerPanel;