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
This commit is contained in:
Jannik committed 2026-09-05 11:57:06 +02:00
1 parent ec12b565be
commit aa3083177d
2 files changed
+202 -48

No files matched your search

+95 -48
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import cytoscape, { Core, ElementDefinition } from 'cytoscape'; import cytoscape, { Core, ElementDefinition } from 'cytoscape';
import { loadLandkarte, Landkarte, VaultNode } from './landkarte'; import { loadLandkarte, Landkarte, VaultNode } from './landkarte';
import { loadNoteContent } from './note-loader'; import { loadNoteContent } from './note-loader';
import ClusterMap from './ClusterMap';
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
inbox: '#5B8FF9', inbox: '#5B8FF9',
@@ -11,6 +12,8 @@ const TYPE_COLORS: Record<string, string> = {
unknown: '#999999', unknown: '#999999',
}; };
type ViewMode = 'graph' | 'cluster';
export default function App() { export default function App() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<Core | null>(null); const cyRef = useRef<Core | null>(null);
@@ -18,6 +21,7 @@ export default function App() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<VaultNode | null>(null); const [selected, setSelected] = useState<VaultNode | null>(null);
const [noteContent, setNoteContent] = useState<string | null>(null); const [noteContent, setNoteContent] = useState<string | null>(null);
const [view, setView] = useState<ViewMode>('graph');
useEffect(() => { useEffect(() => {
loadLandkarte() loadLandkarte()
@@ -26,7 +30,7 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!landkarte || !containerRef.current) return; if (view !== 'graph' || !landkarte || !containerRef.current) return;
const elements: ElementDefinition[] = [ const elements: ElementDefinition[] = [
...landkarte.nodes.map((n) => ({ ...landkarte.nodes.map((n) => ({
@@ -90,7 +94,6 @@ export default function App() {
const node = evt.target.data('node') as VaultNode; const node = evt.target.data('node') as VaultNode;
setSelected(node); setSelected(node);
setNoteContent(null); setNoteContent(null);
// Versuche, Inhalt zu laden
try { try {
const content = await loadNoteContent(node.id); const content = await loadNoteContent(node.id);
setNoteContent(content); setNoteContent(content);
@@ -110,62 +113,106 @@ export default function App() {
cyRef.current?.destroy(); cyRef.current?.destroy();
cyRef.current = null; cyRef.current = null;
}; };
}, [landkarte]); }, [landkarte, view]);
return ( return (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'sans-serif' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', fontFamily: 'sans-serif' }}>
<div ref={containerRef} style={{ flex: 1, background: '#fafafa' }} /> <header style={{
<aside style={{ width: 360, padding: 16, borderLeft: '1px solid #ccc', overflowY: 'auto' }}> display: 'flex',
<h2 style={{ marginTop: 0 }}>Mind-o-Mat</h2> alignItems: 'center',
{error && <p style={{ color: 'red' }}>Fehler: {error}</p>} gap: 16,
{!landkarte && !error && <p>Lade landkarte.json…</p>} padding: '8px 16px',
background: '#222',
color: '#fff',
}}>
<h2 style={{ margin: 0, fontSize: 16 }}>Mind-o-Mat</h2>
<nav style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setView('graph')}
style={tabStyle(view === 'graph')}
>
Graph
</button>
<button
onClick={() => setView('cluster')}
style={tabStyle(view === 'cluster')}
>
Cluster-Karte
</button>
</nav>
{landkarte && ( {landkarte && (
<p style={{ color: '#666', fontSize: 13 }}> <span style={{ marginLeft: 'auto', fontSize: 12, color: '#aaa' }}>
{landkarte.nodes.length} Notizen, {landkarte.edges.length} Wikilinks {landkarte.nodes.length} Notizen, {landkarte.edges.length} Wikilinks
<br /> </span>
<span style={{ fontSize: 11 }}>Generiert: {landkarte.generated}</span>
</p>
)} )}
{selected && ( </header>
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}> <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<h3 style={{ margin: '0 0 8px 0' }}>{selected.title}</h3> <div style={{ flex: 1, overflow: 'hidden' }}>
<dl style={{ fontSize: 12, margin: 0 }}> {view === 'graph' ? (
<dt><b>ID:</b></dt><dd style={{ fontFamily: 'monospace', fontSize: 11 }}>{selected.id}</dd> <div ref={containerRef} style={{ width: '100%', height: '100%', background: '#fafafa' }} />
<dt><b>Typ:</b></dt><dd>{selected.type}</dd> ) : landkarte ? (
{selected.cluster && (<><dt><b>Cluster:</b></dt><dd>{selected.cluster}</dd></>)} <ClusterMap landkarte={landkarte} onSelect={setSelected} />
{selected.status && (<><dt><b>Status:</b></dt><dd>{selected.status}</dd></>)} ) : (
{selected.created && (<><dt><b>Created:</b></dt><dd>{selected.created}</dd></>)} <div style={{ padding: 16 }}>Lade...</div>
{selected.aktualisiert && (<><dt><b>Aktualisiert:</b></dt><dd>{selected.aktualisiert}</dd></>)} )}
{selected.tags.length > 0 && ( </div>
<><dt><b>Tags:</b></dt><dd>{selected.tags.join(', ')}</dd></> <aside style={{ width: 360, padding: 16, borderLeft: '1px solid #ccc', overflowY: 'auto', background: '#fafafa' }}>
{error && <p style={{ color: 'red' }}>Fehler: {error}</p>}
{selected && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<h3 style={{ margin: '0 0 8px 0' }}>{selected.title}</h3>
<dl style={{ fontSize: 12, margin: 0 }}>
<dt><b>ID:</b></dt><dd style={{ fontFamily: 'monospace', fontSize: 11 }}>{selected.id}</dd>
<dt><b>Typ:</b></dt><dd>{selected.type}</dd>
{selected.cluster && (<><dt><b>Cluster:</b></dt><dd>{selected.cluster}</dd></>)}
{selected.status && (<><dt><b>Status:</b></dt><dd>{selected.status}</dd></>)}
{selected.created && (<><dt><b>Created:</b></dt><dd>{selected.created}</dd></>)}
{selected.aktualisiert && (<><dt><b>Aktualisiert:</b></dt><dd>{selected.aktualisiert}</dd></>)}
{selected.tags.length > 0 && (
<><dt><b>Tags:</b></dt><dd>{selected.tags.join(', ')}</dd></>
)}
{selected.veraltet && <><dt><b>Status:</b></dt><dd style={{ color: '#c80' }}>⚠️ veraltet</dd></>}
</dl>
{noteContent && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', fontSize: 12 }}>Vorschau (erste 500 Zeichen)</summary>
<pre style={{
fontSize: 11,
whiteSpace: 'pre-wrap',
background: '#fff',
padding: 8,
marginTop: 6,
borderRadius: 3,
maxHeight: 200,
overflow: 'auto',
}}>
{noteContent.slice(0, 500)}
{noteContent.length > 500 ? '...' : ''}
</pre>
</details>
)} )}
</dl> </div>
{noteContent && ( )}
<details style={{ marginTop: 12 }}> <Legend />
<summary style={{ cursor: 'pointer', fontSize: 12 }}>Vorschau (erste 500 Zeichen)</summary> </aside>
<pre style={{ </div>
fontSize: 11,
whiteSpace: 'pre-wrap',
background: '#fff',
padding: 8,
marginTop: 6,
borderRadius: 3,
maxHeight: 200,
overflow: 'auto',
}}>
{noteContent.slice(0, 500)}
{noteContent.length > 500 ? '...' : ''}
</pre>
</details>
)}
</div>
)}
<Legend />
</aside>
</div> </div>
); );
} }
function tabStyle(active: boolean): React.CSSProperties {
return {
padding: '4px 12px',
background: active ? '#fff' : 'transparent',
color: active ? '#222' : '#aaa',
border: 'none',
borderRadius: 3,
cursor: 'pointer',
fontSize: 13,
fontFamily: 'inherit',
};
}
function Legend() { function Legend() {
return ( return (
<div style={{ marginTop: 24, fontSize: 12 }}> <div style={{ marginTop: 24, fontSize: 12 }}>
+107
View File
@@ -0,0 +1,107 @@
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>
);
}