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:
1 parent
ec12b565be
commit
aa3083177d
2 files changed
+202
-48
No files matched your search
+95
-48
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import cytoscape, { Core, ElementDefinition } from 'cytoscape';
|
||||
import { loadLandkarte, Landkarte, VaultNode } from './landkarte';
|
||||
import { loadNoteContent } from './note-loader';
|
||||
import ClusterMap from './ClusterMap';
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
inbox: '#5B8FF9',
|
||||
@@ -11,6 +12,8 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
unknown: '#999999',
|
||||
};
|
||||
|
||||
type ViewMode = 'graph' | 'cluster';
|
||||
|
||||
export default function App() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<Core | null>(null);
|
||||
@@ -18,6 +21,7 @@ export default function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<VaultNode | null>(null);
|
||||
const [noteContent, setNoteContent] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ViewMode>('graph');
|
||||
|
||||
useEffect(() => {
|
||||
loadLandkarte()
|
||||
@@ -26,7 +30,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!landkarte || !containerRef.current) return;
|
||||
if (view !== 'graph' || !landkarte || !containerRef.current) return;
|
||||
|
||||
const elements: ElementDefinition[] = [
|
||||
...landkarte.nodes.map((n) => ({
|
||||
@@ -90,7 +94,6 @@ export default function App() {
|
||||
const node = evt.target.data('node') as VaultNode;
|
||||
setSelected(node);
|
||||
setNoteContent(null);
|
||||
// Versuche, Inhalt zu laden
|
||||
try {
|
||||
const content = await loadNoteContent(node.id);
|
||||
setNoteContent(content);
|
||||
@@ -110,62 +113,106 @@ export default function App() {
|
||||
cyRef.current?.destroy();
|
||||
cyRef.current = null;
|
||||
};
|
||||
}, [landkarte]);
|
||||
}, [landkarte, view]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', fontFamily: 'sans-serif' }}>
|
||||
<div ref={containerRef} style={{ flex: 1, background: '#fafafa' }} />
|
||||
<aside style={{ width: 360, padding: 16, borderLeft: '1px solid #ccc', overflowY: 'auto' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Mind-o-Mat</h2>
|
||||
{error && <p style={{ color: 'red' }}>Fehler: {error}</p>}
|
||||
{!landkarte && !error && <p>Lade landkarte.json…</p>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', fontFamily: 'sans-serif' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
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 && (
|
||||
<p style={{ color: '#666', fontSize: 13 }}>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#aaa' }}>
|
||||
{landkarte.nodes.length} Notizen, {landkarte.edges.length} Wikilinks
|
||||
<br />
|
||||
<span style={{ fontSize: 11 }}>Generiert: {landkarte.generated}</span>
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
{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></>
|
||||
</header>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{view === 'graph' ? (
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%', background: '#fafafa' }} />
|
||||
) : landkarte ? (
|
||||
<ClusterMap landkarte={landkarte} onSelect={setSelected} />
|
||||
) : (
|
||||
<div style={{ padding: 16 }}>Lade...</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Legend />
|
||||
</aside>
|
||||
</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() {
|
||||
return (
|
||||
<div style={{ marginTop: 24, fontSize: 12 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user