- 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
108 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|