From 71b33e6cecacace1e9df5a59ee4533de525a6e56 Mon Sep 17 00:00:00 2001 From: Till Tomczak Date: Thu, 1 May 2025 16:14:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mindmap):=20enhance=20mindmap?= =?UTF-8?q?=20rendering=20performance=20and=20responsiveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/modules/mindmap.js | 1332 +++++++++++++--------------------- 1 file changed, 517 insertions(+), 815 deletions(-) diff --git a/static/js/modules/mindmap.js b/static/js/modules/mindmap.js index 0dfc9db..bf8613b 100644 --- a/static/js/modules/mindmap.js +++ b/static/js/modules/mindmap.js @@ -1,844 +1,546 @@ /** - * MindMap D3.js Modul - * Visualisiert die Mindmap mit D3.js + * Mindmap-Visualisierungsmodul mit Cytoscape.js + * Erstellt eine interaktive Mindmap-Visualisierung */ -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)); +// Globales MindMap-Objekt erstellen, falls es noch nicht existiert +if (!window.MindMap) { + window.MindMap = {}; +} - 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; +// MindMap Klasse hinzufügen +window.MindMap.Visualization = class MindMapVisualization { + /** + * Konstruktor für die MindMap-Visualisierung + * @param {string} containerId - ID des HTML-Containers für die Visualisierung + * @param {object} options - Optionen für die Visualisierung + */ + constructor(containerId, options = {}) { + this.containerId = containerId; + this.container = document.getElementById(containerId.replace('#', '')); - this.mouseoverNode = null; - this.selectedNode = null; + // Optionen mit Standardwerten + this.options = { + layout: options.layout || 'cose', + darkMode: options.darkMode || document.documentElement.classList.contains('dark'), + nodeClickCallback: options.nodeClickCallback || null, + height: options.height || 600, + ...options + }; - this.zoomFactor = 1; - this.tooltipDiv = null; - this.isLoading = true; + // Cytoscape-Instanz + this.cy = null; - // 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); - + // Vorbereitende Prüfungen + if (!this.container) { + console.error(`Container mit ID ${containerId} nicht gefunden`); 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); + // Cytoscape-Bibliothek prüfen + if (typeof cytoscape === 'undefined') { + this._loadCytoscapeLibrary() + .then(() => this.initialize()) + .catch(error => { + console.error('Cytoscape.js konnte nicht geladen werden:', error); + this._showError('Cytoscape.js konnte nicht geladen werden. Bitte laden Sie die Seite neu.'); + }); } else { - // Node entfernen - this.bookmarkedNodes.splice(index, 1); - this.updateNodeAppearance(nodeId, false); + this.initialize(); } + } + + /** + * Lädt die Cytoscape-Bibliothek dynamisch + * @returns {Promise} Promise, das nach dem Laden erfüllt wird + */ + _loadCytoscapeLibrary() { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.26.0/cytoscape.min.js'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + /** + * Initialisiert die Cytoscape-Instanz + */ + initialize() { + // Container-Dimensionen festlegen + this.container.style.height = `${this.options.height}px`; - // Änderungen speichern - this.saveBookmarkedNodes(); + // Cytoscape-Instanz erstellen + this.cy = cytoscape({ + container: this.container, + elements: [], + style: this._createStylesheet(), + layout: this._getLayoutOptions(), + wheelSensitivity: 0.2, + minZoom: 0.1, + maxZoom: 3, + boxSelectionEnabled: false + }); - // Event auslösen für andere Komponenten - const event = new CustomEvent('nodeBookmarkToggled', { - detail: { - nodeId: nodeId, - isBookmarked: index === -1 + // Event-Listener für Knoten-Klicks + this.cy.on('tap', 'node', event => { + const node = event.target; + + // Alle Knoten zurücksetzen + this.cy.nodes().removeClass('selected'); + + // Ausgewählten Knoten markieren + node.addClass('selected'); + + // Callback aufrufen, falls definiert + if (this.options.nodeClickCallback) { + this.options.nodeClickCallback(node.data()); } }); - document.dispatchEvent(event); - return index === -1; // true wenn jetzt gemerkt, false wenn Markierung aufgehoben + // Event-Listener für Hintergrund-Klicks (Selektion aufheben) + this.cy.on('tap', event => { + if (event.target === this.cy) { + this.cy.nodes().removeClass('selected'); + } + }); + + // Dark Mode Änderungen überwachen + document.addEventListener('darkModeToggled', event => { + this.updateDarkMode(event.detail.isDark); + }); } - // 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); + /** + * Erstellt das Stylesheet für Cytoscape + * @returns {Array} Array mit Stilregeln + */ + _createStylesheet() { + const isDarkMode = this.options.darkMode; + + return [ + { + selector: 'node', + style: { + 'background-color': 'data(color)', + 'label': 'data(name)', + 'width': 30, + 'height': 30, + 'font-size': 12, + 'text-valign': 'bottom', + 'text-halign': 'center', + 'text-margin-y': 8, + 'color': isDarkMode ? '#f1f5f9' : '#334155', + 'text-background-color': isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(241, 245, 249, 0.8)', + 'text-background-opacity': 0.8, + 'text-background-padding': '2px', + 'text-background-shape': 'roundrectangle', + 'text-wrap': 'ellipsis', + 'text-max-width': '100px' + } + }, + { + selector: 'edge', + style: { + 'width': 2, + 'line-color': isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)', + 'target-arrow-color': isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)', + 'curve-style': 'bezier' + } + }, + { + selector: 'node.selected', + style: { + 'background-color': 'data(color)', + 'border-width': 3, + 'border-color': '#8b5cf6', + 'width': 40, + 'height': 40, + 'font-size': 14, + 'font-weight': 'bold', + 'text-background-color': '#8b5cf6', + 'text-background-opacity': 0.9 + } + } + ]; } - // 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); + /** + * Ermittelt die Layout-Optionen basierend auf dem gewählten Layout + * @returns {object} Layout-Optionen für Cytoscape + */ + _getLayoutOptions() { + const layouts = { + 'cose': { + name: 'cose', + animate: true, + animationDuration: 800, + nodeDimensionsIncludeLabels: true, + refresh: 30, + randomize: true, + componentSpacing: 100, + nodeRepulsion: 8000, + nodeOverlap: 20, + idealEdgeLength: 200, + edgeElasticity: 100, + nestingFactor: 1.2, + gravity: 80, + fit: true, + padding: 30 + }, + 'circle': { + name: 'circle', + fit: true, + padding: 50, + radius: 200, + startAngle: 3 / 2 * Math.PI, + sweep: 2 * Math.PI, + clockwise: true, + sort: (a, b) => a.data('name').localeCompare(b.data('name')) + }, + 'concentric': { + name: 'concentric', + fit: true, + padding: 50, + concentric: node => node.data('connections') || 1, + levelWidth: () => 1, + minNodeSpacing: 50 + } + }; + + return layouts[this.options.layout] || layouts['cose']; + } + + /** + * Zeigt eine Fehlermeldung im Container an + * @param {string} message - Die anzuzeigende Fehlermeldung + */ + _showError(message) { + this.container.innerHTML = ` +
+ +

${message}

+

Bitte laden Sie die Seite neu oder kontaktieren Sie den Support.

+
+ `; + } + + /** + * Zeigt eine Ladeanimation im Container an + */ + showLoading() { + this.container.innerHTML = ` +
+
+
+ `; + } + + /** + * Lädt Daten aus der API und rendert sie + */ + loadData() { + // Ladeanimation anzeigen + this.showLoading(); + + // Versuche, Daten vom Server zu laden + fetch('/api/mindmap') + .then(response => { + if (!response.ok) { + throw new Error('Netzwerkfehler beim Laden der Mindmap-Daten'); + } + return response.json(); + }) + .then(data => { + this.renderData(data); + }) + .catch(error => { + console.error('Fehler beim Laden der Mindmap-Daten:', error); + + // Verwende Standarddaten als Fallback + console.log('Verwende Standarddaten als Fallback...'); + const defaultData = this._generateDefaultData(); + this.renderData(defaultData); }); } - + /** - * 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 + * Generiert Standarddaten für die Mindmap als Fallback + * @returns {object} Standarddaten für die Mindmap */ - 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); + _generateDefaultData() { + return { + nodes: [ + { id: 'root', name: 'Wissen', description: 'Zentrale Wissensbasis', category: 'Zentral', color_code: '#4299E1' }, + { id: 'philosophy', name: 'Philosophie', description: 'Philosophisches Denken', category: 'Philosophie', color_code: '#9F7AEA', parent_id: 'root' }, + { id: 'science', name: 'Wissenschaft', description: 'Wissenschaftliche Erkenntnisse', category: 'Wissenschaft', color_code: '#48BB78', parent_id: 'root' }, + { id: 'technology', name: 'Technologie', description: 'Technologische Entwicklungen', category: 'Technologie', color_code: '#ED8936', parent_id: 'root' }, + { id: 'arts', name: 'Künste', description: 'Künstlerische Ausdrucksformen', category: 'Künste', color_code: '#ED64A6', parent_id: 'root' }, + { id: 'psychology', name: 'Psychologie', description: 'Menschliches Verhalten und Geist', category: 'Psychologie', color_code: '#4299E1', parent_id: 'root' } + ] + }; + } + + /** + * Rendert die Daten in der Cytoscape-Instanz + * @param {object} data - Daten für die Mindmap + */ + renderData(data) { + if (!this.cy) { + console.error('Cytoscape-Instanz nicht initialisiert'); + return; + } + + // Konvertiere die Daten in das Cytoscape-Format + const elements = this._convertToCytoscapeFormat(data); + + // Aktualisiere die Elemente + this.cy.elements().remove(); + this.cy.add(elements); + + // Layout neu berechnen und anwenden + const layout = this.cy.layout(this._getLayoutOptions()); + layout.run(); + + // Zentriere und passe die Ansicht an + setTimeout(() => { + this.fitView(); + }, 100); + } + + /** + * Konvertiert die Daten in das Cytoscape-Format + * @param {object} data - Daten für die Mindmap + * @returns {Array} Cytoscape-Elemente + */ + _convertToCytoscapeFormat(data) { + const elements = []; + + // Knoten hinzufügen + if (data.nodes && data.nodes.length > 0) { + data.nodes.forEach(node => { + elements.push({ + group: 'nodes', + data: { + id: String(node.id), + name: node.name, + description: node.description || 'Keine Beschreibung verfügbar', + category: node.category || 'Allgemein', + color: node.color_code || this._getRandomColor(), + connections: 0 + } + }); + + // Kante zum Elternknoten hinzufügen (falls vorhanden) + if (node.parent_id) { + elements.push({ + group: 'edges', + data: { + id: `edge-${node.parent_id}-${node.id}`, + source: String(node.parent_id), + target: String(node.id) + } + }); + } + }); + + // Zusätzliche Kanten zwischen Knoten hinzufügen (falls in den Daten vorhanden) + if (data.edges && data.edges.length > 0) { + data.edges.forEach(edge => { + elements.push({ + group: 'edges', + data: { + id: `edge-${edge.source}-${edge.target}`, + source: String(edge.source), + target: String(edge.target) + } + }); + }); } - if (link.target === nodeId || (link.target && link.target.id === nodeId)) { - connectedIds.add(link.source.id ? link.source.id : link.source); + } + + return elements; + } + + /** + * Generiert eine zufällige Farbe + * @returns {string} Zufällige Farbe als HEX-Code + */ + _getRandomColor() { + const colors = [ + '#4299E1', // Blau + '#9F7AEA', // Lila + '#48BB78', // Grün + '#ED8936', // Orange + '#ED64A6', // Pink + '#F56565' // Rot + ]; + return colors[Math.floor(Math.random() * colors.length)]; + } + + /** + * Aktualisiert den Dark Mode für die Visualisierung + * @param {boolean} isDark - Ob der Dark Mode aktiviert ist + */ + updateDarkMode(isDark) { + if (!this.cy) return; + + this.options.darkMode = isDark; + + // Farben aktualisieren + const textColor = isDark ? '#f1f5f9' : '#334155'; + const textBgColor = isDark ? 'rgba(30, 41, 59, 0.8)' : 'rgba(241, 245, 249, 0.8)'; + const edgeColor = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)'; + + // Stile aktualisieren + this.cy.style() + .selector('node') + .style({ + 'color': textColor, + 'text-background-color': textBgColor + }) + .selector('edge') + .style({ + 'line-color': edgeColor, + 'target-arrow-color': edgeColor + }) + .update(); + } + + /** + * Passt die Ansicht an die Mindmap an + */ + fitView() { + if (!this.cy) return; + + this.cy.fit(); + this.cy.center(); + } + + /** + * Zoomt in die Mindmap hinein + */ + zoomIn() { + if (!this.cy) return; + + const zoom = this.cy.zoom() * 1.2; + this.cy.zoom({ + level: zoom, + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } + }); + } + + /** + * Zoomt aus der Mindmap heraus + */ + zoomOut() { + if (!this.cy) return; + + const zoom = this.cy.zoom() / 1.2; + this.cy.zoom({ + level: zoom, + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } + }); + } + + /** + * Setzt den Zoom zurück + */ + resetZoom() { + if (!this.cy) return; + + this.cy.zoom(1); + this.cy.center(); + } + + /** + * Berechnet das Layout neu + */ + relayout() { + if (!this.cy) return; + + const layout = this.cy.layout(this._getLayoutOptions()); + layout.run(); + } + + /** + * Ändert das Layout + * @param {string} layoutName - Name des Layouts + */ + changeLayout(layoutName) { + if (!this.cy) return; + + this.options.layout = layoutName; + const layout = this.cy.layout(this._getLayoutOptions()); + layout.run(); + } + + /** + * Fokussiert einen Knoten + * @param {string} nodeId - ID des zu fokussierenden Knotens + */ + focusNode(nodeId) { + if (!this.cy) return; + + const node = this.cy.getElementById(nodeId); + if (node.length > 0) { + this.cy.nodes().removeClass('selected'); + node.addClass('selected'); + this.cy.center(node); + + // Callback aufrufen, falls definiert + if (this.options.nodeClickCallback) { + this.options.nodeClickCallback(node.data()); + } + } + } + + /** + * Sucht nach Knoten basierend auf einem Suchbegriff + * @param {string} searchTerm - Suchbegriff + */ + search(searchTerm) { + if (!this.cy || !searchTerm) return; + + const term = searchTerm.toLowerCase(); + let found = false; + + // Alle Knoten durchsuchen + this.cy.nodes().forEach(node => { + const name = node.data('name').toLowerCase(); + const category = node.data('category').toLowerCase(); + const description = node.data('description').toLowerCase(); + + // Prüfen, ob der Suchbegriff enthalten ist + if (name.includes(term) || category.includes(term) || description.includes(term)) { + found = true; + this.cy.nodes().removeClass('selected'); + node.addClass('selected'); + this.cy.center(node); + + // Callback nur für den ersten gefundenen Knoten aufrufen + if (found && this.options.nodeClickCallback) { + this.options.nodeClickCallback(node.data()); + } + + return false; // Schleife abbrechen nach dem ersten Fund } }); - return this.nodes.filter(n => connectedIds.has(n.id)); } -} - -// Exportiere die Klasse für die Verwendung in anderen Modulen -window.MindMapVisualization = MindMapVisualization; \ No newline at end of file + + /** + * Gibt die Daten des ausgewählten Knotens zurück + * @returns {object|null} Daten des ausgewählten Knotens oder null + */ + getSelectedNodeData() { + if (!this.cy) return null; + + const selectedNodes = this.cy.nodes('.selected'); + if (selectedNodes.length > 0) { + return selectedNodes[0].data(); + } + + return null; + } +}; \ No newline at end of file