/** * 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 = `
${d.name}
${d.description || 'Keine Beschreibung verfügbar'}
${d.thought_count > 0 ? `
${d.thought_count} Gedanken verknüpft
` : ''} `; this.tooltipDiv.html(tooltipContent) .style('opacity', 0.95); // Tooltip positionieren (oberhalb des Nodes) const nodeRect = event.target.getBoundingClientRect(); const tooltipWidth = 250; const tooltipHeight = 100; // Ungefähre Höhe des Tooltips const leftPos = nodeRect.left + (nodeRect.width / 2) - (tooltipWidth / 2); const topPos = nodeRect.top - tooltipHeight - 10; // 10px Abstand this.tooltipDiv .style('left', `${leftPos}px`) .style('top', `${topPos}px`) .style('width', `${tooltipWidth}px`); } // Speichern des aktuellen Hover-Nodes this.mouseoverNode = d; // Highlights für verbundene Nodes und Links hinzufügen if (this.g) { // Verbundene Nodes identifizieren const connectedNodes = this.getConnectedNodesById(d.id); const connectedNodeIds = connectedNodes.map(node => node.id); // Alle Nodes etwas transparenter machen this.g.selectAll('.node') .transition() .duration(200) .style('opacity', node => { if (node.id === d.id || connectedNodeIds.includes(node.id)) { return 1.0; } else { return 0.5; } }); // Den Hover-Node hervorheben mit größerem Radius this.g.selectAll('.node') .filter(node => node.id === d.id) .select('circle') .transition() .duration(200) .attr('r', this.nodeRadius * 1.2) .style('filter', 'url(#hover-glow)') .style('stroke', 'rgba(255, 255, 255, 0.25)'); // Verbundene Links hervorheben this.g.selectAll('.link') .transition() .duration(200) .style('opacity', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === d.id || targetId === d.id) { return 0.9; } else { return 0.3; } }) .style('stroke-width', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === d.id || targetId === d.id) { return 3; } else { return 2; } }) .style('stroke', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === d.id || targetId === d.id) { return 'rgba(179, 143, 255, 0.7)'; } else { return 'rgba(255, 255, 255, 0.3)'; } }); } } nodeMouseout(event, d) { if (this.tooltipEnabled) { this.tooltipDiv.transition() .duration(200) .style('opacity', 0); } this.mouseoverNode = null; // Highlights zurücksetzen, falls kein Node ausgewählt ist if (!this.selectedNode && this.g) { // Alle Nodes wieder auf volle Deckkraft setzen this.g.selectAll('.node') .transition() .duration(200) .style('opacity', 1.0); // Hover-Node-Radius zurücksetzen this.g.selectAll('.node') .filter(node => node.id === d.id) .select('circle') .transition() .duration(200) .attr('r', this.nodeRadius) .style('filter', 'none') .style('stroke', 'rgba(255, 255, 255, 0.12)'); // Links zurücksetzen this.g.selectAll('.link') .transition() .duration(200) .style('opacity', 0.7) .style('stroke-width', 2) .style('stroke', 'rgba(255, 255, 255, 0.3)'); } // Falls ein Node ausgewählt ist, den Highlight-Status für diesen beibehalten else if (this.selectedNode && this.g) { const connectedNodes = this.getConnectedNodesById(this.selectedNode.id); const connectedNodeIds = connectedNodes.map(node => node.id); // Alle Nodes auf den richtigen Highlight-Status setzen this.g.selectAll('.node') .transition() .duration(200) .style('opacity', node => { if (node.id === this.selectedNode.id || connectedNodeIds.includes(node.id)) { return 1.0; } else { return 0.5; } }); // Hover-Node zurücksetzen, wenn er nicht der ausgewählte ist if (d.id !== this.selectedNode.id) { this.g.selectAll('.node') .filter(node => node.id === d.id) .select('circle') .transition() .duration(200) .attr('r', this.nodeRadius) .style('filter', 'none') .style('stroke', 'rgba(255, 255, 255, 0.12)'); } // Links auf den richtigen Highlight-Status setzen this.g.selectAll('.link') .transition() .duration(200) .style('opacity', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === this.selectedNode.id || targetId === this.selectedNode.id) { return 0.9; } else { return 0.3; } }) .style('stroke-width', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === this.selectedNode.id || targetId === this.selectedNode.id) { return 3; } else { return 2; } }) .style('stroke', link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; if (sourceId === this.selectedNode.id || targetId === this.selectedNode.id) { return 'rgba(179, 143, 255, 0.7)'; } else { return 'rgba(255, 255, 255, 0.3)'; } }); } } // Findet alle verbundenen Knoten zu einem gegebenen Knoten getConnectedNodes(node) { if (!this.links || !this.nodes || !node) return []; // Sicherstellen, dass der Knoten eine ID hat const nodeId = node.id || node; return this.nodes.filter(n => this.links.some(link => { const sourceId = link.source?.id || link.source; const targetId = link.target?.id || link.target; return (sourceId === nodeId && targetId === n.id) || (targetId === nodeId && sourceId === n.id); }) ); } // Prüft, ob zwei Knoten verbunden sind isConnected(a, b) { if (!this.links || !a || !b) return false; // Sicherstellen, dass die Knoten IDs haben const aId = a.id || a; const bId = b.id || b; return this.links.some(link => { const sourceId = link.source?.id || link.source; const targetId = link.target?.id || link.target; return (sourceId === aId && targetId === bId) || (targetId === aId && sourceId === bId); }); } // Überprüft, ob ein Link zwischen zwei Knoten existiert hasLink(source, target) { if (!this.links || !source || !target) return false; // Sicherstellen, dass die Knoten IDs haben const sourceId = source.id || source; const targetId = target.id || target; return this.links.some(link => { const linkSourceId = link.source?.id || link.source; const linkTargetId = link.target?.id || link.target; return (linkSourceId === sourceId && linkTargetId === targetId) || (linkTargetId === sourceId && linkSourceId === targetId); }); } // Sicherere Methode zum Abrufen verbundener Knoten, die Prüfungen enthält getConnectedNodesById(nodeId) { if (!this.links || !this.nodes || !nodeId) return []; return this.nodes.filter(n => this.links.some(link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; return (sourceId === nodeId && targetId === n.id) || (targetId === nodeId && sourceId === n.id); }) ); } // Aktualisiert die Verbindungszählungen für alle Knoten updateConnectionCounts() { if (!this.nodes || !this.links) return; // Für jeden Knoten die Anzahl der Verbindungen berechnen this.nodes.forEach(node => { // Sichere Methode, um verbundene Knoten zu zählen const connectedNodes = this.nodes.filter(n => n.id !== node.id && this.links.some(link => { const sourceId = link.source.id || link.source; const targetId = link.target.id || link.target; return (sourceId === node.id && targetId === n.id) || (targetId === node.id && sourceId === n.id); }) ); // Speichere die Anzahl als Eigenschaft des Knotens node.connectionCount = connectedNodes.length; }); } // Klick-Handler für Knoten nodeClicked(event, d) { event.preventDefault(); event.stopPropagation(); // Selection-Handling: Knoten auswählen/abwählen if (this.selectedNode === d) { // Wenn der gleiche Knoten geklickt wird, Selektion aufheben this.selectedNode = null; this.nodeElements.classed('selected', false); this.nodeElements .select('circle:not(.node-background):not(.thought-indicator)') .transition() .duration(300) .attr('r', this.nodeRadius) .style('filter', 'url(#glass-with-reflection)') .attr('stroke-width', 2); // Gedankenbereich ausblenden, wenn vorhanden const thoughtContainer = document.getElementById('thought-container'); if (thoughtContainer) { // Sanfte Ausblendanimation thoughtContainer.style.transition = 'all 0.3s ease-out'; thoughtContainer.style.opacity = '0'; thoughtContainer.style.transform = 'translateY(10px)'; setTimeout(() => { // Gedankenbereich komplett ausblenden thoughtContainer.style.display = 'none'; // "Empty state" anzeigen oder andere UI-Anpassungen vornehmen const emptyStateEl = document.getElementById('mindmap-empty-state'); if (emptyStateEl) { emptyStateEl.style.display = 'flex'; emptyStateEl.style.opacity = '0'; setTimeout(() => { emptyStateEl.style.transition = 'all 0.5s ease'; emptyStateEl.style.opacity = '1'; }, 50); } }, 300); } // Alle Kanten zurücksetzen this.linkElements .classed('highlighted', false) .transition() .duration(300) .style('stroke', 'rgba(255, 255, 255, 0.3)') .style('stroke-width', 2) .style('opacity', 0.7); // Interface-Callback für Knoten-Abwahl if (typeof window.onNodeDeselected === 'function') { window.onNodeDeselected(); } // Flash-Nachricht für abgewählten Knoten this.showFlash('Knotenauswahl aufgehoben', 'info', 2000); return; } // Bisher ausgewählten Knoten zurücksetzen if (this.selectedNode) { this.nodeElements .filter(n => n === this.selectedNode) .classed('selected', false) .select('circle:not(.node-background):not(.thought-indicator)') .transition() .duration(300) .attr('r', this.nodeRadius) .style('filter', 'url(#glass-with-reflection)') .attr('stroke-width', 2); } // Neuen Knoten auswählen this.selectedNode = d; // Selected-Klasse für den Knoten setzen this.nodeElements .classed('selected', n => n === d); // Visuelles Feedback für Auswahl this.nodeElements .filter(n => n === d) .select('circle:not(.node-background):not(.thought-indicator)') .transition() .duration(300) .attr('r', this.selectedNodeRadius) .style('filter', 'url(#selected-glow)') .attr('stroke-width', 3) .attr('stroke', 'rgba(179, 143, 255, 0.6)'); // Verbundene Kanten hervorheben const connectedLinks = this.links.filter(link => link.source === d || link.source.id === d.id || link.target === d || link.target.id === d.id ); // Alle Kanten zurücksetzen und dann verbundene hervorheben this.linkElements .classed('highlighted', false) .transition() .duration(300) .style('stroke', 'rgba(255, 255, 255, 0.3)') .style('stroke-width', 2) .style('opacity', 0.7); this.linkElements .filter(link => connectedLinks.some(l => (l.source === link.source || l.source.id === link.source.id) && (l.target === link.target || l.target.id === link.target.id) ) ) .classed('highlighted', true) .transition() .duration(300) .style('stroke', 'rgba(179, 143, 255, 0.7)') .style('stroke-width', 3) .style('opacity', 0.9); // Knoten zentrieren this.centerNodeInView(d); // Gedanken laden this.loadThoughtsForNode(d); // Callback für UI-Integration if (typeof this.onNodeClick === 'function') { this.onNodeClick(d); } // Interface-Callback für externe Verwendung if (typeof window.onNodeSelected === 'function') { window.onNodeSelected(d); } } // Lädt die Gedanken für einen Knoten und zeigt sie an loadThoughtsForNode(node) { // UI-Element für Gedanken finden const thoughtContainer = document.getElementById('thought-container'); const loadingIndicator = document.getElementById('thoughts-loading'); const thoughtsList = document.getElementById('thoughts-list'); const thoughtsTitle = document.getElementById('thoughts-title'); const emptyStateEl = document.getElementById('mindmap-empty-state'); if (!thoughtContainer || !thoughtsList) { console.error('Gedanken-Container nicht gefunden'); this.showFlash('Fehler: Gedanken-Container nicht gefunden', 'error'); return; } // "Empty state" ausblenden if (emptyStateEl) { emptyStateEl.style.transition = 'all 0.3s ease'; emptyStateEl.style.opacity = '0'; setTimeout(() => { emptyStateEl.style.display = 'none'; }, 300); } // Container anzeigen mit Animation thoughtContainer.style.display = 'block'; setTimeout(() => { thoughtContainer.style.transition = 'all 0.4s ease'; thoughtContainer.style.opacity = '1'; thoughtContainer.style.transform = 'translateY(0)'; }, 50); // Titel setzen if (thoughtsTitle) { thoughtsTitle.textContent = `Gedanken zu "${node.name}"`; } // Ladeanimation anzeigen if (loadingIndicator) { loadingIndicator.style.display = 'flex'; } // Bisherige Gedanken leeren if (thoughtsList) { thoughtsList.innerHTML = ''; } // Flash-Nachricht über ausgewählten Knoten this.showFlash(`Knoten "${node.name}" ausgewählt`, 'info'); // Verzögerung für Animation setTimeout(() => { // API-Aufruf für echte Daten aus der Datenbank this.fetchThoughtsForNode(node.id) .then(thoughts => { // Ladeanimation ausblenden if (loadingIndicator) { loadingIndicator.style.display = 'none'; } // Gedanken anzeigen oder "leer"-Zustand if (thoughts && thoughts.length > 0) { this.renderThoughts(thoughts, thoughtsList); } else { this.renderEmptyThoughts(thoughtsList, node); this.showFlash(`Keine Gedanken zu "${node.name}" gefunden`, 'warning'); } }) .catch(error => { console.error('Fehler beim Laden der Gedanken:', error); if (loadingIndicator) { loadingIndicator.style.display = 'none'; } this.renderErrorState(thoughtsList); this.showFlash('Fehler beim Laden der Gedanken. Bitte versuche es später erneut.', 'error'); }); }, 600); // Verzögerung für bessere UX } // Holt Gedanken für einen Knoten aus der Datenbank async fetchThoughtsForNode(nodeId) { try { // Extrahiere die tatsächliche ID aus dem nodeId Format (z.B. "node_123" oder "category_456") const id = nodeId.toString().split('_')[1]; if (!id) { console.warn('Ungültige Node-ID: ', nodeId); this.showFlash('Ungültige Knoten-ID: ' + nodeId, 'warning'); return []; } // API-Aufruf an den entsprechenden Endpunkt const response = await fetch(`/api/nodes/${id}/thoughts`); if (!response.ok) { throw new Error(`API-Fehler: ${response.statusText}`); } const thoughts = await response.json(); console.log('Geladene Gedanken für Knoten:', thoughts); if (thoughts.length > 0) { this.showFlash(`${thoughts.length} Gedanken zum Thema geladen`, 'info'); } else { this.showFlash('Keine Gedanken für diesen Knoten gefunden', 'info'); } return thoughts; } catch (error) { console.error('Fehler beim Laden der Gedanken für Knoten:', error); this.showFlash('Fehler beim Laden der Gedanken', 'error'); return []; } } // Rendert die Gedanken in der UI renderThoughts(thoughts, container) { if (!container) return; container.innerHTML = ''; thoughts.forEach(thought => { const thoughtCard = document.createElement('div'); thoughtCard.className = 'thought-card'; thoughtCard.setAttribute('data-id', thought.id); const cardColor = thought.color_code || this.colorPalette.default; thoughtCard.innerHTML = `

${thought.title}

${new Date(thought.created_at).toLocaleDateString('de-DE')} ${thought.author ? `von ${thought.author.username}` : ''}

${thought.abstract || thought.content.substring(0, 150) + '...'}

`; // Event-Listener für Klick auf Gedanken thoughtCard.addEventListener('click', (e) => { // Verhindern, dass der Link-Klick den Kartenklick auslöst if (e.target.tagName === 'A') return; window.location.href = `/thoughts/${thought.id}`; }); container.appendChild(thoughtCard); }); } // Rendert eine Leermeldung, wenn keine Gedanken vorhanden sind renderEmptyThoughts(container, node) { if (!container) return; const emptyState = document.createElement('div'); emptyState.className = 'empty-thoughts-state'; emptyState.innerHTML = `

Keine Gedanken verknüpft

Zu "${node.name}" sind noch keine Gedanken verknüpft.

Gedanken hinzufügen
`; container.appendChild(emptyState); } // Rendert einen Fehlerzustand renderErrorState(container) { if (!container) return; const errorState = document.createElement('div'); errorState.className = 'error-thoughts-state'; errorState.innerHTML = `

Fehler beim Laden

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;