Elemente: BIM Project Browser + Properties-Satellite-Window
Zwei neue Satellite-Windows (analog Kamera/Text-Editor): 1) Projekt-Übersicht (elemente_uebersicht.py + ElementeUebersichtApp.jsx) - Tree Geschoss → Kind → Element-Instanzen - Suche + Kind-Filter-Chips - Klick = selektieren in Rhino, Shift+Klick = zoomen - Erreichbar via account_tree-Button im Elemente-Panel-Header 2) Properties-Satellite (elemente_properties.py + ElementePropertiesApp.jsx) - Eigenes Fenster mit der PropertiesView (gemeinsame Komponente) - Live-Updates: elemente._send_state forwarded zu satellite-bridge via sticky - Erreichbar via open_in_new-Icon oben rechts in der Properties-Karte - Inline-Properties im Panel bleiben — Satellite ist für mehr Platz Plus ElementeApp-Cleanup: - ElementList (alle Elemente-Liste) raus — wird jetzt von Projekt- Übersicht abgedeckt. - Properties springen bei Selektion nach oben, NeuesElement bleibt voll sichtbar darunter (kein Scrollen mehr). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import Icon from './components/Icon'
|
||||
import { BarToggle, BarButton } from './components/BarControls'
|
||||
import { onMessage, notifyReady, send } from './lib/rhinoBridge'
|
||||
|
||||
// BIM-artige Project-Tree-Ansicht: Geschoss → Kind → Element.
|
||||
// Klick → selektiert in Rhino. Shift-Klick → Zoom-to-Element.
|
||||
|
||||
const KIND_ORDER = [
|
||||
'wand', 'decke', 'dach', 'fenster', 'tuer', 'aussparung',
|
||||
'treppe', 'stuetze', 'traeger', 'raum',
|
||||
]
|
||||
|
||||
const KIND_META = {
|
||||
wand: { icon: 'view_week', label: 'Wände', color: '#888888' },
|
||||
decke: { icon: 'layers', label: 'Decken', color: '#605850' },
|
||||
dach: { icon: 'roofing', label: 'Dächer', color: '#7a4a3a' },
|
||||
fenster: { icon: 'window', label: 'Fenster', color: '#5080c8' },
|
||||
tuer: { icon: 'sensor_door', label: 'Türen', color: '#5080c8' },
|
||||
aussparung: { icon: 'rectangle', label: 'Aussparungen', color: '#a89070' },
|
||||
treppe: { icon: 'stairs', label: 'Treppen', color: '#c87050' },
|
||||
stuetze: { icon: 'square_foot', label: 'Stützen', color: '#c87050' },
|
||||
traeger: { icon: 'horizontal_rule', label: 'Träger', color: '#a87858' },
|
||||
raum: { icon: 'crop_free', label: 'Räume', color: '#5fa896' },
|
||||
}
|
||||
|
||||
|
||||
export default function ElementeUebersichtApp() {
|
||||
const [state, setState] = useState({ geschosse: [], items: [] })
|
||||
const [expanded, setExpanded] = useState({}) // { 'g_id': true, 'g_id::kind': true }
|
||||
const [filter, setFilter] = useState('') // text search
|
||||
const [filterKind, setFilterKind] = useState('') // single kind filter
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE', (s) => setState(s || { geschosse: [], items: [] }))
|
||||
notifyReady()
|
||||
}, [])
|
||||
|
||||
const items = state.items || []
|
||||
const geschosse = state.geschosse || []
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let r = items
|
||||
if (filterKind) r = r.filter(it => it.kind === filterKind)
|
||||
if (filter.trim()) {
|
||||
const q = filter.toLowerCase()
|
||||
r = r.filter(it =>
|
||||
(it.name || '').toLowerCase().includes(q) ||
|
||||
(it.info || '').toLowerCase().includes(q) ||
|
||||
it.kind.toLowerCase().includes(q))
|
||||
}
|
||||
return r
|
||||
}, [items, filter, filterKind])
|
||||
|
||||
// Pre-grouped: g_id -> kind -> [items]
|
||||
const tree = useMemo(() => {
|
||||
const out = {}
|
||||
for (const it of filtered) {
|
||||
const g = it.geschossId || '__keingeschoss__'
|
||||
const k = it.kind
|
||||
if (!out[g]) out[g] = {}
|
||||
if (!out[g][k]) out[g][k] = []
|
||||
out[g][k].push(it)
|
||||
}
|
||||
return out
|
||||
}, [filtered])
|
||||
|
||||
// Counts per kind across all (unfiltered) items — für Filter-Chips
|
||||
const kindCounts = useMemo(() => {
|
||||
const m = {}
|
||||
for (const it of items) m[it.kind] = (m[it.kind] || 0) + 1
|
||||
return m
|
||||
}, [items])
|
||||
|
||||
const toggle = (key) => setExpanded(s => ({ ...s, [key]: !s[key] }))
|
||||
const expandAll = () => {
|
||||
const next = {}
|
||||
for (const g of geschosse) {
|
||||
next[g.id] = true
|
||||
for (const k of KIND_ORDER) {
|
||||
if (tree[g.id]?.[k]) next[g.id + '::' + k] = true
|
||||
}
|
||||
}
|
||||
setExpanded(next)
|
||||
}
|
||||
const collapseAll = () => setExpanded({})
|
||||
|
||||
const onSelect = (item, ev) => {
|
||||
if (ev.shiftKey) {
|
||||
send('ZOOM_TO_ELEMENT', { objectId: item.objectId })
|
||||
} else {
|
||||
send('SELECT_ELEMENT', { objectId: item.objectId })
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = items.length
|
||||
const filteredCount = filtered.length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100vh', overflow: 'hidden',
|
||||
background: 'var(--bg-base)', color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font)', fontSize: 11,
|
||||
}}>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="text" placeholder="Suchen…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{
|
||||
flex: 1, fontSize: 11,
|
||||
padding: '4px 10px',
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 999,
|
||||
color: 'var(--text-primary)',
|
||||
outline: 'none',
|
||||
}} />
|
||||
<BarButton icon="unfold_more" onClick={expandAll} title="Alle aufklappen" />
|
||||
<BarButton icon="unfold_less" onClick={collapseAll} title="Alle einklappen" />
|
||||
</div>
|
||||
{/* Kind-Filter Chips */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
<BarToggle label={`Alle ${totalCount > 0 ? '· ' + totalCount : ''}`}
|
||||
active={!filterKind}
|
||||
onClick={() => setFilterKind('')} />
|
||||
{KIND_ORDER.filter(k => kindCounts[k]).map(k => {
|
||||
const meta = KIND_META[k]
|
||||
return (
|
||||
<BarToggle key={k}
|
||||
icon={meta.icon}
|
||||
label={`${meta.label} ${kindCounts[k]}`}
|
||||
active={filterKind === k}
|
||||
onClick={() => setFilterKind(filterKind === k ? '' : k)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{totalCount === 0 ? (
|
||||
<div style={{
|
||||
padding: 40, textAlign: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center',
|
||||
}}>
|
||||
<Icon name="inventory_2" size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
<div>Keine Elemente im Projekt.</div>
|
||||
<div style={{ fontSize: 10 }}>Wände, Decken, Türen etc. via Elemente-Panel anlegen.</div>
|
||||
</div>
|
||||
) : (
|
||||
geschosse.map(g => {
|
||||
const groupForG = tree[g.id] || {}
|
||||
const total = Object.values(groupForG).reduce((s, arr) => s + arr.length, 0)
|
||||
if (total === 0) return null
|
||||
const gOpen = expanded[g.id] !== false // default: open
|
||||
return (
|
||||
<div key={g.id}>
|
||||
<div onClick={() => toggle(g.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg-section)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
}}>
|
||||
<Icon name={gOpen ? 'expand_more' : 'chevron_right'}
|
||||
size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||
}}>{g.name}</span>
|
||||
{g.okff != null && (
|
||||
<span style={{
|
||||
fontSize: 9, color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
}}>+{g.okff.toFixed(2)} m</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
}}>{total}</span>
|
||||
</div>
|
||||
{gOpen && KIND_ORDER.map(k => {
|
||||
const arr = groupForG[k]
|
||||
if (!arr || arr.length === 0) return null
|
||||
const meta = KIND_META[k]
|
||||
const kKey = g.id + '::' + k
|
||||
const kOpen = expanded[kKey] !== false // default: open
|
||||
return (
|
||||
<div key={kKey}>
|
||||
<div onClick={() => toggle(kKey)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 14px',
|
||||
background: 'var(--bg-item)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
}}>
|
||||
<Icon name={kOpen ? 'expand_more' : 'chevron_right'}
|
||||
size={11} style={{ color: 'var(--text-muted)' }} />
|
||||
<Icon name={meta.icon} size={12} style={{ color: meta.color }} />
|
||||
<span style={{
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
}}>{meta.label}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{
|
||||
fontSize: 9, color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
}}>{arr.length}</span>
|
||||
</div>
|
||||
{kOpen && arr.map((it, idx) => (
|
||||
<div key={it.id}
|
||||
onClick={(ev) => onSelect(it, ev)}
|
||||
title="Klick: selektieren · Shift+Klick: zoomen"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '2px 14px 2px 30px',
|
||||
background: it.selected ? 'var(--active-dim)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
minHeight: 22,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!it.selected) e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!it.selected) e.currentTarget.style.background = 'transparent'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 9, color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
minWidth: 24, textAlign: 'right',
|
||||
}}>{String(idx + 1).padStart(2, '0')}</span>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0,
|
||||
fontSize: 11,
|
||||
color: it.selected ? 'var(--accent-light)' : 'var(--text-label)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{it.name || meta.label}</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
fontFamily: 'DM Mono, monospace',
|
||||
}}>{it.info}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 10px',
|
||||
background: 'var(--bg-section)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
fontSize: 10, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<span>{filteredCount} {filteredCount !== totalCount && `von ${totalCount}`} Elemente</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontStyle: 'italic' }}>
|
||||
Klick = selektieren · Shift+Klick = zoomen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user