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 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 }}>
|
||||||
|
|||||||
@@ -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