Files
Mind-o-Mat/webapp/src/ClusterMap.tsx
T
Jannik aa3083177d Phase 3.1: Cluster-Karte als zweiter Webapp-Tab
- webapp/src/ClusterMap.tsx: HTML/CSS-Grid mit Clustern als Spalten
  - Gruppierung nach cluster-Feld aus landkarte.json
  - Notizen als Karten mit Typ-Farbe + Status-Badge + Veraltet-Warnung
  - Klick auf Karte -> onSelect -> Detail-Panel
- webapp/src/App.tsx: Tab-Wechsel (Graph / Cluster-Karte)
  - Header mit Buttons, ViewMode-State
  - Conditionally rendert entweder Cytoscape-Graph oder ClusterMap
  - Detail-Panel zeigt jetzt auch veraltet-Status
- Build: 34 Module, 593 KB, 2.20s

Verifiziert:
- 'graph' zeigt Cytoscape-Graph wie vorher
- 'cluster' zeigt HTML-Grid mit Clustern als Spalten
- Klick auf Notiz in beiden Tabs -> Detail-Panel rechts
2026-09-05 11:57:06 +02:00

108 lines
3.4 KiB
TypeScript

import { useMemo } from 'react';
import type { Landkarte, VaultNode } from './landkarte';
const TYPE_COLORS: Record<string, string> = {
inbox: '#5B8FF9',
daily: '#5AD8A6',
wiki: '#F6BD16',
project: '#E8684A',
unknown: '#999999',
};
interface Props {
landkarte: Landkarte;
onSelect: (node: VaultNode) => void;
}
/**
* Phase 3.1: Cluster-Karte.
* Zeigt Notizen gruppiert nach Cluster als HTML-Grid.
* Jeder Cluster ist eine Spalte (oder Reihe bei vielen Clustern).
*/
export default function ClusterMap({ landkarte, onSelect }: Props) {
// Gruppierung nach Cluster
const groups = useMemo(() => {
const byCluster = new Map<string, VaultNode[]>();
for (const n of landkarte.nodes) {
const key = n.cluster ?? '(ohne Cluster)';
const list = byCluster.get(key) ?? [];
list.push(n);
byCluster.set(key, list);
}
// Sortierung: Cluster-Name alphabetisch, Notizen nach Typ + ID
const sorted: Array<[string, VaultNode[]]> = [...byCluster.entries()]
.sort((a, b) => a[0].localeCompare(b[0]));
for (const [, list] of sorted) {
list.sort((a, b) => {
const t = a.type.localeCompare(b.type);
return t !== 0 ? t : a.id.localeCompare(b.id);
});
}
return sorted;
}, [landkarte]);
return (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(groups.length, 4)}, 1fr)`,
gap: 16,
padding: 16,
overflow: 'auto',
height: '100%',
background: '#fafafa',
}}>
{groups.map(([cluster, nodes]) => (
<div key={cluster} style={{
background: '#fff',
border: '1px solid #ddd',
borderRadius: 6,
padding: 12,
minWidth: 0,
}}>
<h3 style={{ margin: '0 0 12px 0', fontSize: 14, color: '#333' }}>
{cluster} <span style={{ color: '#999', fontWeight: 'normal' }}>({nodes.length})</span>
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{nodes.map((n) => (
<button
key={n.id}
onClick={() => onSelect(n)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
background: '#f8f8f8',
border: '1px solid #eee',
borderRadius: 3,
cursor: 'pointer',
textAlign: 'left',
fontSize: 12,
fontFamily: 'inherit',
color: '#222',
}}
>
<span style={{
display: 'inline-block',
width: 10,
height: 10,
background: TYPE_COLORS[n.type] ?? '#999',
borderRadius: '50%',
flexShrink: 0,
}} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.title}
</span>
{n.status && (
<span style={{ fontSize: 10, color: '#888' }}>[{n.status}]</span>
)}
{n.veraltet && <span style={{ fontSize: 10, color: '#c80' }}>⚠️</span>}
</button>
))}
</div>
</div>
))}
</div>
);
}