Files
website/static/mindmap.js

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;