1904 lines
61 KiB
JavaScript
1904 lines
61 KiB
JavaScript
/**
|
|
* 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 = '<i class="fas fa-check-circle"></i>';
|
|
break;
|
|
case 'error':
|
|
icon = '<i class="fas fa-exclamation-circle"></i>';
|
|
break;
|
|
case 'warning':
|
|
icon = '<i class="fas fa-exclamation-triangle"></i>';
|
|
break;
|
|
default:
|
|
icon = '<i class="fas fa-info-circle"></i>';
|
|
}
|
|
|
|
// 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 = '<i class="fas fa-times"></i>';
|
|
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 = `
|
|
<div class="font-medium text-lg mb-1.5" style="color: ${this.getNodeColor(d)};">${d.name}</div>
|
|
<div class="mb-2">${d.description || 'Keine Beschreibung verfügbar'}</div>
|
|
${d.thought_count > 0 ? `<div><strong>${d.thought_count}</strong> Gedanken verknüpft</div>` : ''}
|
|
`;
|
|
|
|
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 = `
|
|
<div class="thought-card-header" style="border-left: 4px solid ${cardColor}">
|
|
<h3 class="thought-title">${thought.title}</h3>
|
|
<div class="thought-meta">
|
|
<span class="thought-date">${new Date(thought.created_at).toLocaleDateString('de-DE')}</span>
|
|
${thought.author ? `<span class="thought-author">von ${thought.author.username}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="thought-content">
|
|
<p>${thought.abstract || thought.content.substring(0, 150) + '...'}</p>
|
|
</div>
|
|
<div class="thought-footer">
|
|
<div class="thought-keywords">
|
|
${thought.keywords ? thought.keywords.split(',').map(kw =>
|
|
`<span class="keyword">${kw.trim()}</span>`).join('') : ''}
|
|
</div>
|
|
<a href="/thoughts/${thought.id}" class="thought-link">Mehr lesen →</a>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div class="empty-icon">
|
|
<i class="fas fa-lightbulb"></i>
|
|
</div>
|
|
<h3>Keine Gedanken verknüpft</h3>
|
|
<p>Zu "${node.name}" sind noch keine Gedanken verknüpft.</p>
|
|
<div class="empty-actions">
|
|
<a href="/add-thought?node=${node.id}" class="btn btn-primary">
|
|
<i class="fas fa-plus-circle"></i> Gedanken hinzufügen
|
|
</a>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(emptyState);
|
|
}
|
|
|
|
// Rendert einen Fehlerzustand
|
|
renderErrorState(container) {
|
|
if (!container) return;
|
|
|
|
const errorState = document.createElement('div');
|
|
errorState.className = 'error-thoughts-state';
|
|
|
|
errorState.innerHTML = `
|
|
<div class="error-icon">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
</div>
|
|
<h3>Fehler beim Laden</h3>
|
|
<p>Die Gedanken konnten nicht geladen werden. Bitte versuche es später erneut.</p>
|
|
<button class="btn btn-secondary retry-button">
|
|
<i class="fas fa-redo"></i> Erneut versuchen
|
|
</button>
|
|
`;
|
|
|
|
// 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(`<i class="fa-solid fa-circle-exclamation mr-2"></i> ${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; |