/** * 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 || 22; this.selectedNodeRadius = options.selectedNodeRadius || 28; this.linkDistance = options.linkDistance || 150; this.chargeStrength = options.chargeStrength || -1000; 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; // Flash-Nachrichten-Container this.flashContainer = null; // Erweiterte Farbpalette für Knotentypen this.colorPalette = { 'default': '#b38fff', 'root': '#7e3ff2', 'philosophy': '#58a9ff', 'science': '#38b2ac', 'technology': '#6366f1', 'arts': '#ec4899', 'ai': '#8b5cf6', 'ethics': '#f59e0b', 'math': '#06b6d4', 'psychology': '#10b981', 'biology': '#84cc16', 'literature': '#f43f5e', 'history': '#fb7185', 'economics': '#fbbf24', 'sociology': '#a78bfa', 'design': '#f472b6', 'languages': '#4ade80' }; // Sicherstellen, dass der Container bereit ist if (this.container.node()) { this.init(); this.setupDefaultNodes(); this.setupFlashMessages(); // Sofortige Datenladung window.setTimeout(() => { this.loadData(); }, 100); } else { console.error('Mindmap-Container nicht gefunden:', containerSelector); } } // Flash-Nachrichten-System einrichten setupFlashMessages() { // Flash-Container erstellen, falls er noch nicht existiert if (!document.getElementById('mindmap-flash-container')) { this.flashContainer = document.createElement('div'); this.flashContainer.id = 'mindmap-flash-container'; this.flashContainer.className = 'mindmap-flash-container'; this.flashContainer.style.position = 'fixed'; this.flashContainer.style.top = '20px'; this.flashContainer.style.right = '20px'; this.flashContainer.style.zIndex = '1000'; this.flashContainer.style.maxWidth = '350px'; this.flashContainer.style.display = 'flex'; this.flashContainer.style.flexDirection = 'column'; this.flashContainer.style.gap = '10px'; document.body.appendChild(this.flashContainer); } else { this.flashContainer = document.getElementById('mindmap-flash-container'); } // Prüfen, ob Server-seitige Flash-Nachrichten existieren und anzeigen this.checkForServerFlashMessages(); } // Prüft auf Server-seitige Flash-Nachrichten async checkForServerFlashMessages() { try { const response = await fetch('/api/get_flash_messages'); if (response.ok) { const messages = await response.json(); messages.forEach(message => { this.showFlash(message.message, message.category); }); } } catch (err) { console.error('Fehler beim Abrufen der Flash-Nachrichten:', err); } } // Zeigt eine Flash-Nachricht an showFlash(message, type = 'info', duration = 5000) { if (!this.flashContainer) return; const flashElement = document.createElement('div'); flashElement.className = `mindmap-flash flash-${type}`; flashElement.style.padding = '12px 18px'; flashElement.style.borderRadius = '8px'; flashElement.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; flashElement.style.display = 'flex'; flashElement.style.alignItems = 'center'; flashElement.style.justifyContent = 'space-between'; flashElement.style.fontSize = '14px'; flashElement.style.fontWeight = '500'; flashElement.style.backdropFilter = 'blur(10px)'; flashElement.style.opacity = '0'; flashElement.style.transform = 'translateY(-20px)'; flashElement.style.transition = 'all 0.3s ease'; // Spezifische Stile je nach Nachrichtentyp switch(type) { case 'success': flashElement.style.backgroundColor = 'rgba(34, 197, 94, 0.9)'; flashElement.style.borderLeft = '5px solid #16a34a'; flashElement.style.color = 'white'; break; case 'error': flashElement.style.backgroundColor = 'rgba(239, 68, 68, 0.9)'; flashElement.style.borderLeft = '5px solid #dc2626'; flashElement.style.color = 'white'; break; case 'warning': flashElement.style.backgroundColor = 'rgba(245, 158, 11, 0.9)'; flashElement.style.borderLeft = '5px solid #d97706'; flashElement.style.color = 'white'; break; default: // info flashElement.style.backgroundColor = 'rgba(59, 130, 246, 0.9)'; flashElement.style.borderLeft = '5px solid #2563eb'; flashElement.style.color = 'white'; } // Icon je nach Nachrichtentyp let icon = ''; switch(type) { case 'success': icon = ''; break; case 'error': icon = ''; break; case 'warning': icon = ''; break; default: icon = ''; } // Inhalt der Nachricht mit Icon const contentWrapper = document.createElement('div'); contentWrapper.style.display = 'flex'; contentWrapper.style.alignItems = 'center'; contentWrapper.style.gap = '12px'; const iconElement = document.createElement('div'); iconElement.className = 'flash-icon'; iconElement.innerHTML = icon; const textElement = document.createElement('div'); textElement.className = 'flash-text'; textElement.textContent = message; contentWrapper.appendChild(iconElement); contentWrapper.appendChild(textElement); // Schließen-Button const closeButton = document.createElement('button'); closeButton.className = 'flash-close'; closeButton.innerHTML = ''; closeButton.style.background = 'none'; closeButton.style.border = 'none'; closeButton.style.color = 'currentColor'; closeButton.style.cursor = 'pointer'; closeButton.style.marginLeft = '15px'; closeButton.style.padding = '3px'; closeButton.style.fontSize = '14px'; closeButton.style.opacity = '0.7'; closeButton.style.transition = 'opacity 0.2s'; closeButton.addEventListener('mouseover', () => { closeButton.style.opacity = '1'; }); closeButton.addEventListener('mouseout', () => { closeButton.style.opacity = '0.7'; }); closeButton.addEventListener('click', () => { this.removeFlash(flashElement); }); // Zusammenfügen flashElement.appendChild(contentWrapper); flashElement.appendChild(closeButton); // Zum Container hinzufügen this.flashContainer.appendChild(flashElement); // Animation einblenden setTimeout(() => { flashElement.style.opacity = '1'; flashElement.style.transform = 'translateY(0)'; }, 10); // Automatisches Ausblenden nach der angegebenen Zeit if (duration > 0) { setTimeout(() => { this.removeFlash(flashElement); }, duration); } return flashElement; } // Entfernt eine Flash-Nachricht mit Animation removeFlash(flashElement) { if (!flashElement) return; flashElement.style.opacity = '0'; flashElement.style.transform = 'translateY(-20px)'; setTimeout(() => { if (flashElement.parentNode) { flashElement.parentNode.removeChild(flashElement); } }, 300); } // 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: 3 }, { id: "philosophy", name: "Philosophie", description: "Philosophisches Denken", thought_count: 2 }, { id: "science", name: "Wissenschaft", description: "Wissenschaftliche Erkenntnisse", thought_count: 5 }, { id: "technology", name: "Technologie", description: "Technologische Entwicklungen", thought_count: 7 }, { id: "arts", name: "Künste", description: "Künstlerische Ausdrucksformen", thought_count: 4 }, { id: "ai", name: "Künstliche Intelligenz", description: "KI-Forschung und Anwendungen", thought_count: 6 }, { id: "ethics", name: "Ethik", description: "Moralische Grundsätze", thought_count: 2 }, { id: "math", name: "Mathematik", description: "Mathematische Konzepte", thought_count: 3 }, { id: "psychology", name: "Psychologie", description: "Menschliches Verhalten und Kognition", thought_count: 4 }, { id: "biology", name: "Biologie", description: "Lebenswissenschaften", thought_count: 3 }, { id: "literature", name: "Literatur", description: "Literarische Werke und Analysen", thought_count: 2 } ]; const defaultLinks = [ { source: "root", target: "philosophy" }, { source: "root", target: "science" }, { source: "root", target: "technology" }, { source: "root", target: "arts" }, { source: "science", target: "math" }, { source: "science", target: "biology" }, { source: "technology", target: "ai" }, { source: "philosophy", target: "ethics" }, { source: "philosophy", target: "psychology" }, { source: "arts", target: "literature" }, { source: "ai", target: "ethics" }, { source: "psychology", target: "biology" } ]; // 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 und Loading-State ausblenden const loadingOverlay = this.container.select('.mindmap-loading'); if (loadingOverlay) { loadingOverlay.style('opacity', 0.8); } 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'); // SVG-Definitionen für Filter und Effekte const defs = this.g.append('defs'); // Verbesserte Glasmorphismus- und Glow-Effekte // Basis Glow-Effekt D3Extensions.createGlowFilter(defs, 'glow-effect', '#b38fff', 8); // Spezifische Effekte für verschiedene Zustände D3Extensions.createGlowFilter(defs, 'hover-glow', '#58a9ff', 6); D3Extensions.createGlowFilter(defs, 'selected-glow', '#b38fff', 10); // Schatten für alle Knoten D3Extensions.createShadowFilter(defs, 'shadow-effect'); // Glasmorphismus-Effekt für Knoten D3Extensions.createGlassMorphismFilter(defs, 'glass-effect'); // Erweiterte Effekte this.createAdvancedNodeEffects(defs); // Tooltip initialisieren mit verbessertem Glasmorphism-Stil 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(24, 28, 45, 0.85)') .style('color', '#ffffff') .style('border', '1px solid rgba(179, 143, 255, 0.3)') .style('border-radius', '16px') .style('padding', '12px 16px') .style('font-size', '14px') .style('font-weight', '500') .style('line-height', '1.5') .style('max-width', '280px') .style('box-shadow', '0 12px 30px rgba(0, 0, 0, 0.5), 0 0 15px rgba(179, 143, 255, 0.25)') .style('backdrop-filter', 'blur(20px)') .style('-webkit-backdrop-filter', 'blur(20px)') .style('z-index', '1000'); } else { this.tooltipDiv = d3.select('body').select('.node-tooltip'); } // Initialisierung abgeschlossen - Loading-Overlay ausblenden setTimeout(() => { if (loadingOverlay) { loadingOverlay.transition() .duration(500) .style('opacity', 0) .on('end', function() { loadingOverlay.style('display', 'none'); }); } }, 1000); } // 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.5)); // Globale Mindmap-Instanz für externe Zugriffe setzen window.mindmapInstance = this; } // Erstellt erweiterte Effekte für die Knoten createAdvancedNodeEffects(defs) { // Verbesserte innere Leuchteffekte für Knoten const innerGlow = defs.append('filter') .attr('id', 'inner-glow') .attr('x', '-50%') .attr('y', '-50%') .attr('width', '200%') .attr('height', '200%'); // Farbiger innerer Glühen innerGlow.append('feGaussianBlur') .attr('in', 'SourceAlpha') .attr('stdDeviation', 2) .attr('result', 'blur'); innerGlow.append('feOffset') .attr('in', 'blur') .attr('dx', 0) .attr('dy', 0) .attr('result', 'offsetBlur'); innerGlow.append('feFlood') .attr('flood-color', 'rgba(179, 143, 255, 0.8)') .attr('result', 'glowColor'); innerGlow.append('feComposite') .attr('in', 'glowColor') .attr('in2', 'offsetBlur') .attr('operator', 'in') .attr('result', 'innerGlow'); // Verbinden der Filter const innerGlowMerge = innerGlow.append('feMerge'); innerGlowMerge.append('feMergeNode') .attr('in', 'innerGlow'); innerGlowMerge.append('feMergeNode') .attr('in', 'SourceGraphic'); // 3D-Glaseffekt mit verbesserter Tiefe D3Extensions.create3DGlassEffect(defs, '3d-glass'); // Pulseffekt für Hervorhebung const pulseFilter = defs.append('filter') .attr('id', 'pulse-effect') .attr('x', '-50%') .attr('y', '-50%') .attr('width', '200%') .attr('height', '200%'); // Animation definieren const pulseAnimation = pulseFilter.append('feComponentTransfer') .append('feFuncA') .attr('type', 'linear') .attr('slope', '1.5'); } // Behandelt die Zoom-Transformation für die SVG handleZoom(transform) { this.g.attr('transform', transform); this.zoomFactor = transform.k; // Knotengröße an Zoom anpassen if (this.nodeElements) { this.nodeElements.selectAll('circle') .attr('r', d => { return d === this.selectedNode ? this.selectedNodeRadius / Math.sqrt(transform.k) : this.nodeRadius / Math.sqrt(transform.k); }); this.textElements .style('font-size', `${16 / Math.sqrt(transform.k)}px`); } } // Lädt die Mindmap-Daten async loadData() { try { // Zeige Lade-Animation this.showLoading(); // API-Aufruf durchführen, um die Kategorien und ihre Knoten zu laden const response = await fetch('/api/mindmap/public'); if (!response.ok) { throw new Error('API-Fehler: ' + response.statusText); } const data = await response.json(); console.log('Geladene Mindmap-Daten:', data); // Verarbeite die hierarchischen Daten in flache Knoten und Links const processed = this.processApiData(data); this.nodes = processed.nodes; this.links = processed.links; // Verbindungszählungen aktualisieren this.updateConnectionCounts(); // Visualisierung aktualisieren this.updateVisualization(); // Lade-Animation ausblenden this.hideLoading(); // Erfolgreiche Ladung melden this.showFlash('Mindmap-Daten erfolgreich geladen', 'success'); } catch (error) { console.error('Fehler beim Laden der Mindmap-Daten:', error); // Bei einem Fehler die Fallback-Daten verwenden this.nodes = this.defaultNodes; this.links = this.defaultLinks; // Verbindungszählungen auch für Fallback-Daten aktualisieren this.updateConnectionCounts(); // Fehler anzeigen this.showError('Mindmap-Daten konnten nicht geladen werden. Verwende Standarddaten.'); this.showFlash('Fehler beim Laden der Mindmap-Daten. Standarddaten werden angezeigt.', 'error'); // Visualisierung auch im Fehlerfall aktualisieren this.updateVisualization(); this.hideLoading(); } } // Verarbeitet die API-Daten in das benötigte Format processApiData(apiData) { // Erstelle einen Root-Knoten, der alle Kategorien verbindet const rootNode = { id: "root", name: "Wissen", description: "Zentrale Wissensbasis", thought_count: 0 }; let nodes = [rootNode]; let links = []; // Für jede Kategorie Knoten und Verbindungen erstellen apiData.forEach(category => { // Kategorie als Knoten hinzufügen const categoryNode = { id: `category_${category.id}`, name: category.name, description: category.description, color_code: category.color_code, icon: category.icon, thought_count: 0, type: 'category' }; nodes.push(categoryNode); // Mit Root-Knoten verbinden links.push({ source: "root", target: categoryNode.id }); // Alle Knoten aus dieser Kategorie hinzufügen if (category.nodes && category.nodes.length > 0) { category.nodes.forEach(node => { // Zähle die Gedanken für die Kategorie categoryNode.thought_count += node.thought_count || 0; const mindmapNode = { id: `node_${node.id}`, name: node.name, description: node.description || '', color_code: node.color_code || category.color_code, thought_count: node.thought_count || 0, type: 'node', categoryId: category.id }; nodes.push(mindmapNode); // Mit Kategorie-Knoten verbinden links.push({ source: categoryNode.id, target: mindmapNode.id }); }); } // Rekursiv Unterkategorien verarbeiten if (category.children && category.children.length > 0) { this.processSubcategories(category.children, nodes, links, categoryNode.id); } }); // Root-Knoten-Gedankenzähler aktualisieren rootNode.thought_count = nodes.reduce((sum, node) => sum + (node.thought_count || 0), 0); return { nodes, links }; } // Verarbeitet Unterkategorien rekursiv processSubcategories(subcategories, nodes, links, parentId) { subcategories.forEach(category => { // Kategorie als Knoten hinzufügen const categoryNode = { id: `category_${category.id}`, name: category.name, description: category.description, color_code: category.color_code, icon: category.icon, thought_count: 0, type: 'subcategory' }; nodes.push(categoryNode); // Mit Eltern-Kategorie verbinden links.push({ source: parentId, target: categoryNode.id }); // Alle Knoten aus dieser Kategorie hinzufügen if (category.nodes && category.nodes.length > 0) { category.nodes.forEach(node => { // Zähle die Gedanken für die Kategorie categoryNode.thought_count += node.thought_count || 0; const mindmapNode = { id: `node_${node.id}`, name: node.name, description: node.description || '', color_code: node.color_code || category.color_code, thought_count: node.thought_count || 0, type: 'node', categoryId: category.id }; nodes.push(mindmapNode); // Mit Kategorie-Knoten verbinden links.push({ source: categoryNode.id, target: mindmapNode.id }); }); } // Rekursiv Unterkategorien verarbeiten if (category.children && category.children.length > 0) { this.processSubcategories(category.children, nodes, links, categoryNode.id); } }); } // Zeigt den Ladebildschirm an showLoading() { const loadingOverlay = this.container.select('.mindmap-loading'); if (loadingOverlay && !loadingOverlay.empty()) { loadingOverlay .style('display', 'flex') .style('opacity', 1); // Ladebalken-Animation const progressBar = loadingOverlay.select('.loading-progress'); if (!progressBar.empty()) { let progress = 0; const progressInterval = setInterval(() => { progress += Math.random() * 15; if (progress > 90) { progress = 90 + Math.random() * 5; clearInterval(progressInterval); } updateProgress(progress); }, 200); function updateProgress(progress) { progressBar.style('width', `${Math.min(progress, 95)}%`); } } } } // Blendet den Ladebildschirm aus hideLoading() { const loadingOverlay = this.container.select('.mindmap-loading'); if (loadingOverlay && !loadingOverlay.empty()) { // Lade-Fortschritt auf 100% setzen const progressBar = loadingOverlay.select('.loading-progress'); if (!progressBar.empty()) { progressBar.transition() .duration(300) .style('width', '100%'); } // Overlay ausblenden setTimeout(() => { loadingOverlay.transition() .duration(500) .style('opacity', 0) .on('end', function() { loadingOverlay.style('display', 'none'); }); }, 400); } } // Verarbeitet hierarchische Daten in flache Knoten und Links processHierarchicalData(hierarchicalNodes, parentId = null) { let nodes = []; let links = []; for (const node of hierarchicalNodes) { nodes.push({ id: node.id, name: node.name, description: node.description || '', thought_count: node.thought_count || 0 }); if (parentId) { links.push({ source: parentId, target: node.id }); } if (node.children && node.children.length > 0) { const { nodes: childNodes, links: childLinks } = this.processHierarchicalData(node.children, node.id); nodes = [...nodes, ...childNodes]; links = [...links, ...childLinks]; } } return { nodes, links }; } // Generiert eine konsistente Farbe basierend auf dem Knotennamen generateColorFromString(str) { const colors = [ '#b38fff', '#58a9ff', '#14b8a6', '#f472b6', '#84cc16', '#f97316', '#4c1d95', '#2dd4bf', '#ec4899', '#eab308' ]; let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]; } /** * Aktualisiert die Visualisierung basierend auf den aktuellen Daten */ updateVisualization() { if (!this.g || !this.nodes.length) return; // Daten für Simulation vorbereiten // Kopieren der Knoten und Links, um Referenzen zu erhalten const nodes = this.nodes.map(d => Object.assign({}, d)); const links = this.links.map(d => Object.assign({}, d)); // Links erstellen oder aktualisieren this.linkElements = this.g.selectAll('.link') .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`); // Links entfernen, die nicht mehr existieren this.linkElements.exit().remove(); // Neue Links erstellen const linkEnter = this.linkElements .enter().append('path') .attr('class', 'link') .attr('stroke-width', 2) .attr('stroke', 'rgba(255, 255, 255, 0.3)') .attr('fill', 'none') .attr('marker-end', 'url(#arrowhead)'); // Alle Links aktualisieren this.linkElements = linkEnter.merge(this.linkElements); // Pfeilspitzen für die Links definieren if (!this.g.select('#arrowhead').size()) { this.g.append('defs').append('marker') .attr('id', 'arrowhead') .attr('viewBox', '0 -5 10 10') .attr('refX', 28) // Abstand vom Ende des Links zum Knoten .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('xoverflow', 'visible') .append('path') .attr('d', 'M 0,-3 L 8,0 L 0,3') .attr('fill', 'rgba(255, 255, 255, 0.6)'); } // Knoten erstellen oder aktualisieren this.nodeElements = this.g.selectAll('.node') .data(nodes, d => d.id); // Knoten entfernen, die nicht mehr existieren this.nodeElements.exit().remove(); // Container für neue Knoten erstellen const nodeEnter = this.nodeElements .enter().append('g') .attr('class', 'node') .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)) ) .on('mouseover', (event, d) => this.nodeMouseover(event, d)) .on('mouseout', (event, d) => this.nodeMouseout(event, d)) .on('click', (event, d) => this.nodeClicked(event, d)); // Kreisformen für die Knoten hinzufügen nodeEnter.append('circle') .attr('r', d => this.nodeRadius) .attr('fill', d => this.getNodeColor(d)) .attr('stroke', 'rgba(255, 255, 255, 0.12)') .attr('stroke-width', 2) .style('filter', 'url(#glass-effect)'); // Label für die Knoten hinzufügen nodeEnter.append('text') .attr('class', 'node-label') .attr('dy', 4) .attr('text-anchor', 'middle') .text(d => this.truncateNodeLabel(d.name)) .style('font-size', d => D3Extensions.getAdaptiveFontSize(d.name, 16, 10) + 'px'); // Alle Knoten aktualisieren this.nodeElements = nodeEnter.merge(this.nodeElements); // Simulation mit den neuen Daten aktualisieren this.simulation .nodes(nodes) .force('link', d3.forceLink(links).id(d => d.id).distance(this.linkDistance)); // Simulation-Tick-Funktion setzen this.simulation.on('tick', () => this.ticked()); // Simulation neustarten this.simulation.alpha(1).restart(); // Nach kurzer Verzögerung die Knoten mit zusätzlichen Effekten versehen setTimeout(() => { this.nodeElements.selectAll('circle') .transition() .duration(500) .attr('r', d => { // Größe abhängig von der Anzahl der Gedanken const baseRadius = this.nodeRadius; const bonus = d.thought_count ? Math.min(d.thought_count / 3, 6) : 0; return baseRadius + bonus; }); }, 300); } // Lange Knotenbeschriftungen abkürzen truncateNodeLabel(label) { if (!label) return ''; const maxLength = 18; // Maximale Zeichenlänge if (label.length <= maxLength) { return label; } else { return label.substring(0, maxLength - 3) + '...'; } } // Bestimmt die Farbe eines Knotens basierend auf seinem Typ oder direkt angegebener Farbe getNodeColor(node) { // Direkt angegebene Farbe verwenden, wenn vorhanden if (node.color_code) { return node.color_code; } // Kategorietyp-basierte Färbung if (node.type === 'category' || node.type === 'subcategory') { return this.colorPalette.root; } // Fallback für verschiedene Knotentypen return this.colorPalette[node.id] || this.colorPalette.default; } // Aktualisiert die Positionen in jedem Simulationsschritt ticked() { if (!this.linkElements || !this.nodeElements) return; // Aktualisierung der Linkpositionen mit gebogenem Pfad this.linkElements .attr('d', d => { const dx = d.target.x - d.source.x; const dy = d.target.y - d.source.y; const dr = Math.sqrt(dx * dx + dy * dy) * 1.5; // Kurvenstärke return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`; }); // Aktualisierung der Knotenpositionen this.nodeElements .attr('transform', d => `translate(${d.x},${d.y})`); } // D3.js Drag-Funktionen 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; } // Hover-Effekte für Knoten nodeMouseover(event, d) { if (this.tooltipEnabled) { // Tooltip-Inhalt erstellen const tooltipContent = `
${thought.abstract || thought.content.substring(0, 150) + '...'}
Zu "${node.name}" sind noch keine Gedanken verknüpft.
`; container.appendChild(emptyState); } // Rendert einen Fehlerzustand renderErrorState(container) { if (!container) return; const errorState = document.createElement('div'); errorState.className = 'error-thoughts-state'; errorState.innerHTML = `Die Gedanken konnten nicht geladen werden. Bitte versuche es später erneut.
`; // Event-Listener für Retry-Button const retryButton = errorState.querySelector('.retry-button'); if (retryButton && this.selectedNode) { retryButton.addEventListener('click', () => { this.loadThoughtsForNode(this.selectedNode); }); } container.appendChild(errorState); } // Zentriert einen Knoten in der Ansicht centerNodeInView(node) { // Sanfter Übergang zur Knotenzentrierüng const transform = d3.zoomTransform(this.svg.node()); const scale = transform.k; const x = -node.x * scale + this.width / 2; const y = -node.y * scale + this.height / 2; this.svg.transition() .duration(750) .call( d3.zoom().transform, d3.zoomIdentity.translate(x, y).scale(scale) ); // Flash-Nachricht für Zentrierung if (node && node.name) { this.showFlash(`Ansicht auf "${node.name}" zentriert`, 'info', 2000); } } // Fehlermeldung anzeigen showError(message) { // Standard-Fehlermeldung als Banner const errorBanner = d3.select('body').selectAll('.error-banner').data([0]); const errorEnter = errorBanner.enter() .append('div') .attr('class', 'error-banner') .style('position', 'fixed') .style('bottom', '-100px') .style('left', '50%') .style('transform', 'translateX(-50%)') .style('background', 'rgba(220, 38, 38, 0.9)') .style('color', 'white') .style('padding', '12px 20px') .style('border-radius', '8px') .style('z-index', '1000') .style('box-shadow', '0 10px 25px rgba(0, 0, 0, 0.3)') .style('font-weight', '500') .style('max-width', '90%') .style('text-align', 'center'); const banner = errorEnter.merge(errorBanner); banner.html(` ${message}`) .transition() .duration(500) .style('bottom', '20px') .transition() .delay(5000) .duration(500) .style('bottom', '-100px'); // Auch als Flash-Nachricht anzeigen this.showFlash(message, 'error'); } // Fokussieren auf einen bestimmten Knoten per ID focusNode(nodeId) { const targetNode = this.nodes.find(n => n.id === nodeId); if (!targetNode) { this.showFlash(`Knoten mit ID "${nodeId}" nicht gefunden`, 'error'); return; } // Ausgewählten Zustand zurücksetzen this.selectedNode = null; // Node-Klick simulieren this.nodeClicked(null, targetNode); // Fokussieren mit einer Animation if (targetNode.x && targetNode.y) { const transform = d3.zoomIdentity .translate(this.width / 2 - targetNode.x * 1.2, this.height / 2 - targetNode.y * 1.2) .scale(1.2); this.svg.transition() .duration(750) .call( d3.zoom().transform, transform ); this.showFlash(`Fokus auf Knoten "${targetNode.name}" gesetzt`, 'success'); } } // Filtert Knoten nach Suchbegriff filterBySearchTerm(searchTerm) { if (!searchTerm || searchTerm.trim() === '') { // Alle Knoten anzeigen, wenn kein Suchbegriff this.nodeElements .style('display', 'block') .selectAll('circle') .style('opacity', 1); this.textElements .style('opacity', 1); this.linkElements .style('display', 'block') .style('stroke-opacity', 0.5); this.showFlash('Suchfilter zurückgesetzt', 'info', 2000); return; } searchTerm = searchTerm.toLowerCase().trim(); // Knoten finden, die dem Suchbegriff entsprechen const matchingNodes = this.nodes.filter(node => node.name.toLowerCase().includes(searchTerm) || (node.description && node.description.toLowerCase().includes(searchTerm)) ); const matchingNodeIds = matchingNodes.map(n => n.id); // Nur passende Knoten und ihre Verbindungen anzeigen this.nodeElements .style('display', d => matchingNodeIds.includes(d.id) ? 'block' : 'none') .selectAll('circle') .style('opacity', 1); this.textElements .style('opacity', d => matchingNodeIds.includes(d.id) ? 1 : 0.2); this.linkElements .style('display', link => matchingNodeIds.includes(link.source.id) && matchingNodeIds.includes(link.target.id) ? 'block' : 'none') .style('stroke-opacity', 0.7); // Wenn nur ein Knoten gefunden wurde, darauf fokussieren if (matchingNodes.length === 1) { this.focusNode(matchingNodes[0].id); } // Wenn mehr als ein Knoten gefunden wurde, Simulation mit reduzierter Stärke neu starten if (matchingNodes.length > 1) { this.simulation.alpha(0.3).restart(); this.showFlash(`${matchingNodes.length} Knoten für "${searchTerm}" gefunden`, 'success'); } else if (matchingNodes.length === 0) { this.showFlash(`Keine Knoten für "${searchTerm}" gefunden`, 'warning'); } } } // D3-Erweiterungen für spezielle Effekte class D3Extensions { static createGlowFilter(defs, id, color = '#b38fff', strength = 5) { const filter = defs.append('filter') .attr('id', id) .attr('height', '300%') .attr('width', '300%') .attr('x', '-100%') .attr('y', '-100%'); // Farbe und Sättigung const colorMatrix = filter.append('feColorMatrix') .attr('type', 'matrix') .attr('values', ` 1 0 0 0 ${color === '#b38fff' ? 0.7 : 0.35} 0 1 0 0 ${color === '#58a9ff' ? 0.7 : 0.35} 0 0 1 0 ${color === '#58a9ff' ? 0.7 : 0.55} 0 0 0 1 0 `) .attr('result', 'colored'); // Weichzeichner für Glühen const blur = filter.append('feGaussianBlur') .attr('in', 'colored') .attr('stdDeviation', strength) .attr('result', 'blur'); // Kombination von Original und Glühen const merge = filter.append('feMerge'); merge.append('feMergeNode') .attr('in', 'blur'); merge.append('feMergeNode') .attr('in', 'SourceGraphic'); return filter; } static createShadowFilter(defs, id) { const filter = defs.append('filter') .attr('id', id) .attr('height', '200%') .attr('width', '200%') .attr('x', '-50%') .attr('y', '-50%'); // Offset der Lichtquelle const offset = filter.append('feOffset') .attr('in', 'SourceAlpha') .attr('dx', 3) .attr('dy', 4) .attr('result', 'offset'); // Weichzeichnung für Schatten const blur = filter.append('feGaussianBlur') .attr('in', 'offset') .attr('stdDeviation', 5) .attr('result', 'blur'); // Schatten-Opazität const opacity = filter.append('feComponentTransfer'); opacity.append('feFuncA') .attr('type', 'linear') .attr('slope', 0.3); // Zusammenführen const merge = filter.append('feMerge'); merge.append('feMergeNode'); merge.append('feMergeNode') .attr('in', 'SourceGraphic'); return filter; } static createGlassMorphismFilter(defs, id) { const filter = defs.append('filter') .attr('id', id) .attr('width', '300%') .attr('height', '300%') .attr('x', '-100%') .attr('y', '-100%'); // Basis-Hintergrundfarbe const bgColor = filter.append('feFlood') .attr('flood-color', 'rgba(24, 28, 45, 0.75)') .attr('result', 'bgColor'); // Weichzeichnung des Originalelements const blur = filter.append('feGaussianBlur') .attr('in', 'SourceGraphic') .attr('stdDeviation', '3') .attr('result', 'blur'); // Komposition des Glaseffekts mit Original const composite1 = filter.append('feComposite') .attr('in', 'bgColor') .attr('in2', 'blur') .attr('operator', 'in') .attr('result', 'glass'); // Leichter Farbakzent const colorMatrix = filter.append('feColorMatrix') .attr('in', 'glass') .attr('type', 'matrix') .attr('values', '1 0 0 0 0.1 0 1 0 0 0.1 0 0 1 0 0.3 0 0 0 1 0') .attr('result', 'coloredGlass'); // Leichte Transparenz an den Rändern const specLight = filter.append('feSpecularLighting') .attr('in', 'blur') .attr('surfaceScale', '3') .attr('specularConstant', '0.75') .attr('specularExponent', '20') .attr('lighting-color', '#ffffff') .attr('result', 'specLight'); specLight.append('fePointLight') .attr('x', '-20') .attr('y', '-30') .attr('z', '120'); // Lichtkombination const composite2 = filter.append('feComposite') .attr('in', 'specLight') .attr('in2', 'coloredGlass') .attr('operator', 'in') .attr('result', 'lightedGlass'); // Alle Effekte kombinieren const merge = filter.append('feMerge') .attr('result', 'glassMerge'); merge.append('feMergeNode') .attr('in', 'coloredGlass'); merge.append('feMergeNode') .attr('in', 'lightedGlass'); merge.append('feMergeNode') .attr('in', 'SourceGraphic'); return filter; } // Erstellt einen erweiterten 3D-Glaseffekt mit Lichtreflexion static create3DGlassEffect(defs, id) { const filter = defs.append('filter') .attr('id', id) .attr('width', '300%') .attr('height', '300%') .attr('x', '-100%') .attr('y', '-100%'); // Hintergrund-Färbung mit Transparenz const bgColor = filter.append('feFlood') .attr('flood-color', 'rgba(24, 28, 45, 0.7)') .attr('result', 'bgColor'); // Alpha-Kanal modifizieren const composite1 = filter.append('feComposite') .attr('in', 'bgColor') .attr('in2', 'SourceAlpha') .attr('operator', 'in') .attr('result', 'shape'); // Leichte Unschärfe hinzufügen const blur = filter.append('feGaussianBlur') .attr('in', 'shape') .attr('stdDeviation', '2') .attr('result', 'blurredShape'); // Lichtquelle für 3D-Effekt const specLight = filter.append('feSpecularLighting') .attr('in', 'blurredShape') .attr('surfaceScale', '5') .attr('specularConstant', '1') .attr('specularExponent', '20') .attr('lighting-color', '#ffffff') .attr('result', 'specLight'); specLight.append('fePointLight') .attr('x', '50') .attr('y', '-50') .attr('z', '200'); // Farbmatrix für Lichttönung const colorMatrix = filter.append('feColorMatrix') .attr('in', 'specLight') .attr('type', 'matrix') .attr('values', '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0') .attr('result', 'coloredLight'); // Alle Effekte kombinieren const merge = filter.append('feMerge'); merge.append('feMergeNode') .attr('in', 'blurredShape'); merge.append('feMergeNode') .attr('in', 'coloredLight'); merge.append('feMergeNode') .attr('in', 'SourceGraphic'); return filter; } } // Globales Objekt für Zugriff außerhalb des Moduls window.MindMapVisualization = MindMapVisualization;