/**
* MindMap D3.js Modul
* Visualisiert die Mindmap mit D3.js
*/
class MindMapVisualization {
constructor(containerSelector, options = {}) {
this.containerSelector = containerSelector;
this.container = d3.select(containerSelector);
this.width = options.width || this.container.node().clientWidth || 800;
this.height = options.height || 600;
this.nodeRadius = options.nodeRadius || 14;
this.selectedNodeRadius = options.selectedNodeRadius || 20;
this.linkDistance = options.linkDistance || 150;
this.chargeStrength = options.chargeStrength || -900;
this.centerForce = options.centerForce || 0.15;
this.onNodeClick = options.onNodeClick || ((node) => console.log('Node clicked:', node));
this.nodes = [];
this.links = [];
this.simulation = null;
this.svg = null;
this.linkElements = null;
this.nodeElements = null;
this.textElements = null;
this.tooltipEnabled = options.tooltipEnabled !== undefined ? options.tooltipEnabled : true;
this.mouseoverNode = null;
this.selectedNode = null;
this.zoomFactor = 1;
this.tooltipDiv = null;
this.isLoading = true;
// Lade die gemerkten Knoten
this.bookmarkedNodes = this.loadBookmarkedNodes();
// Sicherstellen, dass der Container bereit ist
if (this.container.node()) {
this.init();
this.setupDefaultNodes();
// Sofortige Datenladung
window.setTimeout(() => {
this.loadData();
}, 100);
} else {
console.error('Mindmap-Container nicht gefunden:', containerSelector);
}
}
// Standardknoten als Fallback einrichten, falls die API nicht reagiert
setupDefaultNodes() {
// Basis-Mindmap mit Hauptthemen
const defaultNodes = [
{ id: "root", name: "Wissen", description: "Zentrale Wissensbasis", thought_count: 0 },
{ id: "philosophy", name: "Philosophie", description: "Philosophisches Denken", thought_count: 0 },
{ id: "science", name: "Wissenschaft", description: "Wissenschaftliche Erkenntnisse", thought_count: 0 },
{ id: "technology", name: "Technologie", description: "Technologische Entwicklungen", thought_count: 0 },
{ id: "arts", name: "Künste", description: "Künstlerische Ausdrucksformen", thought_count: 0 }
];
const defaultLinks = [
{ source: "root", target: "philosophy" },
{ source: "root", target: "science" },
{ source: "root", target: "technology" },
{ source: "root", target: "arts" }
];
// Als Fallback verwenden, falls die API fehlschlägt
this.defaultNodes = defaultNodes;
this.defaultLinks = defaultLinks;
}
init() {
// SVG erstellen, wenn noch nicht vorhanden
if (!this.svg) {
// Container zuerst leeren
this.container.html('');
this.svg = this.container
.append('svg')
.attr('width', '100%')
.attr('height', this.height)
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
.attr('class', 'mindmap-svg')
.call(
d3.zoom()
.scaleExtent([0.1, 5])
.on('zoom', (event) => {
this.handleZoom(event.transform);
})
);
// Hauptgruppe für alles, was zoom-transformierbar ist
this.g = this.svg.append('g');
// Tooltip initialisieren
if (!d3.select('body').select('.node-tooltip').size()) {
this.tooltipDiv = d3.select('body')
.append('div')
.attr('class', 'node-tooltip')
.style('opacity', 0)
.style('position', 'absolute')
.style('pointer-events', 'none')
.style('background', 'rgba(20, 20, 40, 0.9)')
.style('color', '#ffffff')
.style('border', '1px solid rgba(160, 80, 255, 0.2)')
.style('border-radius', '6px')
.style('padding', '8px 12px')
.style('font-size', '14px')
.style('max-width', '250px')
.style('box-shadow', '0 10px 25px rgba(0, 0, 0, 0.5), 0 0 10px rgba(160, 80, 255, 0.2)');
} else {
this.tooltipDiv = d3.select('body').select('.node-tooltip');
}
}
// Force-Simulation initialisieren
this.simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(this.linkDistance))
.force('charge', d3.forceManyBody().strength(this.chargeStrength))
.force('center', d3.forceCenter(this.width / 2, this.height / 2).strength(this.centerForce))
.force('collision', d3.forceCollide().radius(this.nodeRadius * 2));
// Globale Mindmap-Instanz für externe Zugriffe setzen
window.mindmapInstance = this;
}
handleZoom(transform) {
this.g.attr('transform', transform);
this.zoomFactor = transform.k;
// Knotengröße anpassen, um bei Zoom lesbar zu bleiben
if (this.nodeElements) {
this.nodeElements
.attr('r', d => (d === this.selectedNode ? this.selectedNodeRadius : this.nodeRadius) / Math.sqrt(transform.k));
}
// Textgröße anpassen
if (this.textElements) {
this.textElements
.style('font-size', `${12 / Math.sqrt(transform.k)}px`);
}
}
async loadData() {
try {
// Ladeindikator anzeigen
this.showLoading();
// Verwende sofort die Standarddaten für eine schnelle erste Anzeige
this.nodes = [...this.defaultNodes];
this.links = [...this.defaultLinks];
// Visualisierung sofort aktualisieren
this.isLoading = false;
this.updateVisualization();
// Status auf bereit setzen - don't wait for API
this.container.attr('data-status', 'ready');
// API-Aufruf mit kürzerem Timeout im Hintergrund durchführen
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 Sekunden Timeout
const response = await fetch('/api/mindmap', {
signal: controller.signal,
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
clearTimeout(timeoutId);
if (!response.ok) {
console.warn(`HTTP Fehler: ${response.status}, versuche erneute Verbindung`);
// Bei Verbindungsfehler versuchen, die Verbindung neu herzustellen
const retryResponse = await fetch('/api/refresh-mindmap', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!retryResponse.ok) {
throw new Error(`Retry failed with status: ${retryResponse.status}`);
}
const retryData = await retryResponse.json();
if (!retryData.success || !retryData.nodes || retryData.nodes.length === 0) {
console.warn('Keine Mindmap-Daten nach Neuversuch, verwende weiterhin Standard-Daten.');
return; // Keep using default data
}
// Flache Liste von Knoten und Verbindungen erstellen
this.nodes = [];
this.links = [];
// Knoten direkt übernehmen
retryData.nodes.forEach(node => {
this.nodes.push({
id: node.id,
name: node.name,
description: node.description || '',
thought_count: node.thought_count || 0,
color: this.generateColorFromString(node.name),
});
// Verbindungen hinzufügen
if (node.connections && node.connections.length > 0) {
node.connections.forEach(conn => {
this.links.push({
source: node.id,
target: conn.target
});
});
}
});
// Visualisierung aktualisieren mit den tatsächlichen Daten
this.updateVisualization();
return;
}
const data = await response.json();
if (!data || !data.nodes || data.nodes.length === 0) {
console.warn('Keine Mindmap-Daten vorhanden, verwende weiterhin Standard-Daten.');
return; // Keep using default data
}
// Flache Liste von Knoten und Verbindungen erstellen
this.nodes = [];
this.links = [];
this.processHierarchicalData(data.nodes);
// Visualisierung aktualisieren mit den tatsächlichen Daten
this.updateVisualization();
} catch (error) {
console.warn('Fehler beim Laden der Mindmap-Daten, verwende Standarddaten:', error);
// Already using default data, no action needed
}
} catch (error) {
console.error('Kritischer Fehler bei der Mindmap-Darstellung:', error);
this.showError('Fehler beim Laden der Mindmap-Daten. Bitte laden Sie die Seite neu.');
this.container.attr('data-status', 'error');
}
}
showLoading() {
// Element nur leeren, wenn es noch kein SVG enthält
if (!this.container.select('svg').size()) {
this.container.html(`
`);
}
}
processHierarchicalData(hierarchicalNodes, parentId = null) {
hierarchicalNodes.forEach(node => {
// Knoten hinzufügen, wenn noch nicht vorhanden
if (!this.nodes.find(n => n.id === node.id)) {
this.nodes.push({
id: node.id,
name: node.name,
description: node.description || '',
thought_count: node.thought_count || 0,
color: this.generateColorFromString(node.name),
});
}
// Verbindung zum Elternknoten hinzufügen
if (parentId !== null) {
this.links.push({
source: parentId,
target: node.id
});
}
// Rekursiv für Kindknoten aufrufen
if (node.children && node.children.length > 0) {
this.processHierarchicalData(node.children, node.id);
}
});
}
generateColorFromString(str) {
// Erzeugt eine deterministische Farbe basierend auf dem String
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Verwende deterministische Farbe aus unserem Farbschema
const colors = [
'#4080ff', // primary-400
'#a040ff', // secondary-400
'#205cf5', // primary-500
'#8020f5', // secondary-500
'#1040e0', // primary-600
'#6010e0', // secondary-600
];
return colors[Math.abs(hash) % colors.length];
}
updateVisualization() {
// Starte die Visualisierung nur, wenn nicht mehr im Ladezustand
if (this.isLoading) return;
// Container leeren, wenn Diagramm neu erstellt wird
if (!this.svg) {
this.container.html('');
this.init();
}
// Performance-Optimierung: Deaktiviere Transition während des Datenladens
const useTransitions = false;
// Links (Edges) erstellen
this.linkElements = this.g.selectAll('.link')
.data(this.links)
.join(
enter => enter.append('line')
.attr('class', 'link')
.attr('stroke', '#ffffff30')
.attr('stroke-width', 2)
.attr('stroke-dasharray', d => d.relation_type === 'contradicts' ? '5,5' : null)
.attr('marker-end', d => d.relation_type === 'builds_upon' ? 'url(#arrowhead)' : null),
update => update
.attr('stroke', '#ffffff30')
.attr('stroke-dasharray', d => d.relation_type === 'contradicts' ? '5,5' : null)
.attr('marker-end', d => d.relation_type === 'builds_upon' ? 'url(#arrowhead)' : null),
exit => exit.remove()
);
// Pfeilspitze für gerichtete Beziehungen hinzufügen (falls noch nicht vorhanden)
if (!this.svg.select('defs').node()) {
const defs = this.svg.append('defs');
defs.append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#ffffff50');
}
// Simplified Effekte definieren, falls noch nicht vorhanden
if (!this.svg.select('#glow').node()) {
const defs = this.svg.select('defs').size() ? this.svg.select('defs') : this.svg.append('defs');
// Glow-Effekt für Knoten
const filter = defs.append('filter')
.attr('id', 'glow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
filter.append('feGaussianBlur')
.attr('stdDeviation', '1')
.attr('result', 'blur');
filter.append('feComposite')
.attr('in', 'SourceGraphic')
.attr('in2', 'blur')
.attr('operator', 'over');
// Blur-Effekt für Schatten
const blurFilter = defs.append('filter')
.attr('id', 'blur')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
blurFilter.append('feGaussianBlur')
.attr('stdDeviation', '1');
}
// Knoten-Gruppe erstellen/aktualisieren
const nodeGroups = this.g.selectAll('.node-group')
.data(this.nodes)
.join(
enter => {
const group = enter.append('g')
.attr('class', 'node-group')
.call(d3.drag()
.on('start', (event, d) => this.dragStarted(event, d))
.on('drag', (event, d) => this.dragged(event, d))
.on('end', (event, d) => this.dragEnded(event, d)));
// Hintergrundschatten für besseren Kontrast
group.append('circle')
.attr('class', 'node-shadow')
.attr('r', d => this.nodeRadius * 1.2)
.attr('fill', 'rgba(0, 0, 0, 0.3)')
.attr('filter', 'url(#blur)');
// Kreis für jeden Knoten
group.append('circle')
.attr('class', d => `node ${this.isNodeBookmarked(d.id) ? 'bookmarked' : ''}`)
.attr('r', this.nodeRadius)
.attr('fill', d => d.color || this.generateColorFromString(d.name))
.attr('stroke', d => this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff50')
.attr('stroke-width', d => this.isNodeBookmarked(d.id) ? 3 : 2)
.attr('filter', 'url(#glow)');
// Text-Label mit besserem Kontrast
group.append('text')
.attr('class', 'node-label')
.attr('dy', '0.35em')
.attr('text-anchor', 'middle')
.attr('fill', '#ffffff')
.attr('stroke', 'rgba(0, 0, 0, 0.4)')
.attr('stroke-width', '0.7px')
.attr('paint-order', 'stroke')
.style('font-size', '12px')
.style('font-weight', '500')
.style('pointer-events', 'none')
.text(d => d.name.length > 12 ? d.name.slice(0, 10) + '...' : d.name);
// Interaktivität hinzufügen
group
.on('mouseover', (event, d) => this.nodeMouseover(event, d))
.on('mouseout', (event, d) => this.nodeMouseout(event, d))
.on('click', (event, d) => this.nodeClicked(event, d));
return group;
},
update => {
// Knoten aktualisieren
update.select('.node')
.attr('class', d => `node ${this.isNodeBookmarked(d.id) ? 'bookmarked' : ''}`)
.attr('fill', d => d.color || this.generateColorFromString(d.name))
.attr('stroke', d => this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff50')
.attr('stroke-width', d => this.isNodeBookmarked(d.id) ? 3 : 2);
// Text aktualisieren
update.select('.node-label')
.text(d => d.name.length > 12 ? d.name.slice(0, 10) + '...' : d.name);
return update;
},
exit => exit.remove()
);
// Einzelne Elemente für direkten Zugriff speichern
this.nodeElements = this.g.selectAll('.node');
this.textElements = this.g.selectAll('.node-label');
// Performance-Optimierung: Weniger Simulationsschritte für schnellere Stabilisierung
this.simulation
.nodes(this.nodes)
.on('tick', () => this.ticked())
.alpha(0.3) // Reduzierter Wert für schnellere Stabilisierung
.alphaDecay(0.05); // Erhöhter Wert für schnellere Stabilisierung
this.simulation.force('link')
.links(this.links);
// Simulation neu starten
this.simulation.restart();
// Update connection counts
this.updateConnectionCounts();
}
ticked() {
// Linienpositionen aktualisieren
this.linkElements
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
// Knotenpositionen aktualisieren
this.g.selectAll('.node-group')
.attr('transform', d => `translate(${d.x}, ${d.y})`);
}
dragStarted(event, d) {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
dragEnded(event, d) {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
nodeMouseover(event, d) {
this.mouseoverNode = d;
// Tooltip anzeigen
if (this.tooltipEnabled) {
const isBookmarked = this.isNodeBookmarked(d.id);
const tooltipContent = `
${d.name}
${d.description ? `
${d.description}
` : ''}
Gedanken: ${d.thought_count}
`;
this.tooltipDiv
.html(tooltipContent)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px')
.transition()
.duration(200)
.style('opacity', 1);
// Event-Listener für den Bookmark-Button hinzufügen
document.getElementById('bookmark-button').addEventListener('click', (e) => {
e.stopPropagation();
const nodeId = e.currentTarget.getAttribute('data-nodeid');
const isNowBookmarked = this.toggleBookmark(nodeId);
// Button-Text aktualisieren
if (isNowBookmarked) {
e.currentTarget.innerHTML = ' Gemerkt';
} else {
e.currentTarget.innerHTML = ' Merken';
}
});
}
// Knoten visuell hervorheben
d3.select(event.currentTarget).select('circle')
.transition()
.duration(200)
.attr('r', this.nodeRadius * 1.2)
.attr('stroke', this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff');
}
nodeMouseout(event, d) {
this.mouseoverNode = null;
// Tooltip ausblenden
if (this.tooltipEnabled) {
this.tooltipDiv
.transition()
.duration(200)
.style('opacity', 0);
}
// Knoten-Stil zurücksetzen, wenn nicht ausgewählt
const nodeElement = d3.select(event.currentTarget).select('circle');
if (d !== this.selectedNode) {
const isBookmarked = this.isNodeBookmarked(d.id);
nodeElement
.transition()
.duration(200)
.attr('r', this.nodeRadius)
.attr('stroke', isBookmarked ? '#FFD700' : '#ffffff50')
.attr('stroke-width', isBookmarked ? 3 : 2);
}
}
nodeClicked(event, d) {
// Frühere Auswahl zurücksetzen
if (this.selectedNode && this.selectedNode !== d) {
this.g.selectAll('.node')
.filter(n => n === this.selectedNode)
.transition()
.duration(200)
.attr('r', this.nodeRadius)
.attr('stroke', '#ffffff50');
}
// Neue Auswahl hervorheben
if (this.selectedNode !== d) {
this.selectedNode = d;
d3.select(event.currentTarget).select('circle')
.transition()
.duration(200)
.attr('r', this.selectedNodeRadius)
.attr('stroke', '#ffffff');
}
// Callback mit Node-Daten aufrufen
this.onNodeClick(d);
}
showError(message) {
this.container.html(`
`);
}
// Fokussiert die Ansicht auf einen bestimmten Knoten
focusNode(nodeId) {
const node = this.nodes.find(n => n.id === nodeId);
if (!node) return;
// Simuliere einen Klick auf den Knoten
const nodeElement = this.g.selectAll('.node-group')
.filter(d => d.id === nodeId);
nodeElement.dispatch('click');
// Zentriere den Knoten in der Ansicht
const transform = d3.zoomIdentity
.translate(this.width / 2, this.height / 2)
.scale(1.2)
.translate(-node.x, -node.y);
this.svg.transition()
.duration(750)
.call(
d3.zoom().transform,
transform
);
}
// Filtert die Mindmap basierend auf einem Suchbegriff
filterBySearchTerm(searchTerm) {
if (!searchTerm || searchTerm.trim() === '') {
// Alle Knoten anzeigen
this.g.selectAll('.node-group')
.style('opacity', 1)
.style('pointer-events', 'all');
this.g.selectAll('.link')
.style('opacity', 1);
return;
}
const searchLower = searchTerm.toLowerCase();
const matchingNodes = this.nodes.filter(node =>
node.name.toLowerCase().includes(searchLower) ||
(node.description && node.description.toLowerCase().includes(searchLower))
);
const matchingNodeIds = new Set(matchingNodes.map(n => n.id));
// Passende Knoten hervorheben, andere ausblenden
this.g.selectAll('.node-group')
.style('opacity', d => matchingNodeIds.has(d.id) ? 1 : 0.2)
.style('pointer-events', d => matchingNodeIds.has(d.id) ? 'all' : 'none');
// Verbindungen zwischen passenden Knoten hervorheben
this.g.selectAll('.link')
.style('opacity', d =>
matchingNodeIds.has(d.source.id) && matchingNodeIds.has(d.target.id) ? 1 : 0.1
);
// Auf den ersten passenden Knoten fokussieren, wenn vorhanden
if (matchingNodes.length > 0) {
this.focusNode(matchingNodes[0].id);
}
}
/**
* Updates the thought_count property for each node based on existing connections
*/
updateConnectionCounts() {
// Reset all counts first
this.nodes.forEach(node => {
// Initialize thought_count if it doesn't exist
if (typeof node.thought_count !== 'number') {
node.thought_count = 0;
}
// Count connections for this node
const connectedNodes = this.getConnectedNodes(node);
node.thought_count = connectedNodes.length;
});
// Update UI to show counts
this.updateNodeLabels();
}
/**
* Updates the visual representation of node labels to include connection counts
*/
updateNodeLabels() {
if (!this.textElements) return;
this.textElements.text(d => {
if (d.thought_count > 0) {
return `${d.name} (${d.thought_count})`;
}
return d.name;
});
}
/**
* Adds a new connection between nodes and updates the counts
*/
addConnection(sourceNode, targetNode) {
if (!sourceNode || !targetNode) return false;
// Check if connection already exists
if (this.isConnected(sourceNode, targetNode)) return false;
// Add new connection
this.links.push({
source: sourceNode.id,
target: targetNode.id
});
// Update counts
this.updateConnectionCounts();
// Update visualization
this.updateVisualization();
return true;
}
// Lädt gemerkete Knoten aus dem LocalStorage
loadBookmarkedNodes() {
try {
const bookmarked = localStorage.getItem('bookmarkedNodes');
return bookmarked ? JSON.parse(bookmarked) : [];
} catch (error) {
console.error('Fehler beim Laden der gemerkten Knoten:', error);
return [];
}
}
// Speichert gemerkete Knoten im LocalStorage
saveBookmarkedNodes() {
try {
localStorage.setItem('bookmarkedNodes', JSON.stringify(this.bookmarkedNodes));
} catch (error) {
console.error('Fehler beim Speichern der gemerkten Knoten:', error);
}
}
// Prüft, ob ein Knoten gemerkt ist
isNodeBookmarked(nodeId) {
return this.bookmarkedNodes.includes(nodeId);
}
// Merkt einen Knoten oder hebt die Markierung auf
toggleBookmark(nodeId) {
const index = this.bookmarkedNodes.indexOf(nodeId);
if (index === -1) {
// Node hinzufügen
this.bookmarkedNodes.push(nodeId);
this.updateNodeAppearance(nodeId, true);
} else {
// Node entfernen
this.bookmarkedNodes.splice(index, 1);
this.updateNodeAppearance(nodeId, false);
}
// Änderungen speichern
this.saveBookmarkedNodes();
// Event auslösen für andere Komponenten
const event = new CustomEvent('nodeBookmarkToggled', {
detail: {
nodeId: nodeId,
isBookmarked: index === -1
}
});
document.dispatchEvent(event);
return index === -1; // true wenn jetzt gemerkt, false wenn Markierung aufgehoben
}
// Aktualisiert das Aussehen eines Knotens basierend auf Bookmark-Status
updateNodeAppearance(nodeId, isBookmarked) {
this.g.selectAll('.node-group')
.filter(d => d.id === nodeId)
.select('.node')
.classed('bookmarked', isBookmarked)
.attr('stroke', isBookmarked ? '#FFD700' : '#ffffff50')
.attr('stroke-width', isBookmarked ? 3 : 2);
}
// Aktualisiert das Aussehen aller gemerkten Knoten
updateAllBookmarkedNodes() {
this.g.selectAll('.node-group')
.each((d) => {
const isBookmarked = this.isNodeBookmarked(d.id);
this.updateNodeAppearance(d.id, isBookmarked);
});
}
/**
* Gibt alle direkt verbundenen Knoten eines Knotens zurück
* @param {Object} node - Der Knoten, für den die Verbindungen gesucht werden
* @returns {Array} Array der verbundenen Knotenobjekte
*/
getConnectedNodes(node) {
if (!node || !this.links || !this.nodes) return [];
const nodeId = node.id;
const connectedIds = new Set();
this.links.forEach(link => {
if (link.source === nodeId || (link.source && link.source.id === nodeId)) {
connectedIds.add(link.target.id ? link.target.id : link.target);
}
if (link.target === nodeId || (link.target && link.target.id === nodeId)) {
connectedIds.add(link.source.id ? link.source.id : link.source);
}
});
return this.nodes.filter(n => connectedIds.has(n.id));
}
}
// Exportiere die Klasse für die Verwendung in anderen Modulen
window.MindMapVisualization = MindMapVisualization;