/** * 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(`

Mindmap wird geladen...

`); } } 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(`

${message}

`); } // 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;