2061 lines
71 KiB
JavaScript
2061 lines
71 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 || 800; // Erhöhung der Standardhöhe für bessere Sichtbarkeit
|
|
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;
|
|
|
|
// Authentischere Farbpalette mit dezenten, professionellen Farben
|
|
this.colorPalette = {
|
|
'default': '#6b7280', // Grau
|
|
'root': '#4f46e5', // Indigo
|
|
'philosophy': '#3b82f6', // Blau
|
|
'science': '#10b981', // Smaragdgrün
|
|
'technology': '#6366f1', // Indigo
|
|
'arts': '#8b5cf6', // Violett
|
|
'ai': '#7c3aed', // Violett
|
|
'ethics': '#475569', // Slate
|
|
'math': '#0369a1', // Dunkelblau
|
|
'psychology': '#0284c7', // Hellblau
|
|
'biology': '#059669', // Grün
|
|
'literature': '#4338ca', // Indigo
|
|
'history': '#0f766e', // Teal
|
|
'economics': '#374151', // Dunkelgrau
|
|
'sociology': '#4f46e5', // Indigo
|
|
'design': '#0891b2', // Cyan
|
|
'languages': '#2563eb' // Blau
|
|
};
|
|
|
|
// 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 mit verbesserter Anpassung
|
|
handleZoom(transform) {
|
|
this.g.attr('transform', transform);
|
|
this.zoomFactor = transform.k;
|
|
|
|
// Knotengröße dynamisch an Zoom anpassen für bessere Lesbarkeit
|
|
if (this.nodeElements) {
|
|
// Berechne relativen Radius basierend auf Zoom
|
|
const nodeScaleFactor = 1 / Math.sqrt(transform.k);
|
|
const minRadiusFactor = 0.6; // Minimale Größe beim Herauszoomen
|
|
const maxRadiusFactor = 1.2; // Maximale Größe beim Hineinzoomen
|
|
|
|
// Beschränke den Skalierungsfaktor in sinnvollen Grenzen
|
|
const cappedScaleFactor = Math.max(minRadiusFactor, Math.min(nodeScaleFactor, maxRadiusFactor));
|
|
|
|
this.nodeElements.selectAll('circle')
|
|
.attr('r', d => {
|
|
return d === this.selectedNode
|
|
? this.selectedNodeRadius * cappedScaleFactor
|
|
: this.nodeRadius * cappedScaleFactor;
|
|
})
|
|
.attr('stroke-width', 2 * cappedScaleFactor); // Strichstärke anpassen
|
|
|
|
// Schriftgröße dynamisch anpassen
|
|
this.nodeElements.selectAll('text')
|
|
.style('font-size', `${14 * cappedScaleFactor}px`)
|
|
.attr('dy', 4 * cappedScaleFactor);
|
|
|
|
// Verbindungslinien anpassen
|
|
this.linkElements
|
|
.attr('stroke-width', 1.5 * cappedScaleFactor)
|
|
.attr('marker-end', transform.k < 0.6 ? 'none' : 'url(#arrowhead)'); // Pfeile bei starkem Zoom ausblenden
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// Authentischere, dezente Farben für wissenschaftliche Darstellung
|
|
const colors = [
|
|
'#4f46e5', '#0369a1', '#0f766e', '#374151', '#4338ca',
|
|
'#0284c7', '#059669', '#475569', '#6366f1', '#0891b2'
|
|
];
|
|
|
|
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 mit dezenten, wissenschaftlichen Farben
|
|
if (node.type === 'category') {
|
|
return this.colorPalette.root;
|
|
} else if (node.type === 'subcategory') {
|
|
return this.colorPalette.science;
|
|
}
|
|
|
|
// 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 mit verbesserter Seitenleisten-Interaktion
|
|
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);
|
|
|
|
// Event auslösen: Knoten abgewählt
|
|
document.dispatchEvent(new CustomEvent('mindmap-node-deselected'));
|
|
|
|
// Standardinformationspanels wieder anzeigen
|
|
this.showDefaultSidebar();
|
|
|
|
// 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(75, 85, 99, 0.4)')
|
|
.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);
|
|
|
|
// Event auslösen: Knoten ausgewählt
|
|
document.dispatchEvent(new CustomEvent('mindmap-node-selected', {
|
|
detail: 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(79, 70, 229, 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(75, 85, 99, 0.4)')
|
|
.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(79, 70, 229, 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);
|
|
}
|
|
}
|
|
|
|
// Neue Methode: Zeigt die Standardseitenleiste an (Über die Mindmap und Kategorien)
|
|
showDefaultSidebar() {
|
|
// Finde die Seitenleistenelemente
|
|
const aboutMindmapPanel = document.querySelector('[data-sidebar="about-mindmap"]');
|
|
const categoriesPanel = document.querySelector('[data-sidebar="categories"]');
|
|
const nodeDescriptionPanel = document.querySelector('[data-sidebar="node-description"]');
|
|
|
|
if (aboutMindmapPanel && categoriesPanel && nodeDescriptionPanel) {
|
|
// Beschreibungspanel ausblenden
|
|
nodeDescriptionPanel.style.display = 'none';
|
|
|
|
// Standardpanels einblenden mit Animation
|
|
aboutMindmapPanel.style.display = 'block';
|
|
categoriesPanel.style.display = 'block';
|
|
|
|
setTimeout(() => {
|
|
aboutMindmapPanel.style.opacity = '1';
|
|
aboutMindmapPanel.style.transform = 'translateY(0)';
|
|
|
|
categoriesPanel.style.opacity = '1';
|
|
categoriesPanel.style.transform = 'translateY(0)';
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
// Neue Methode: Zeigt die Knotenbeschreibung in der Seitenleiste an
|
|
showNodeDescriptionSidebar(node) {
|
|
// Finde die Seitenleistenelemente
|
|
const aboutMindmapPanel = document.querySelector('[data-sidebar="about-mindmap"]');
|
|
const categoriesPanel = document.querySelector('[data-sidebar="categories"]');
|
|
const nodeDescriptionPanel = document.querySelector('[data-sidebar="node-description"]');
|
|
|
|
if (aboutMindmapPanel && categoriesPanel && nodeDescriptionPanel) {
|
|
// Standardpanels ausblenden
|
|
aboutMindmapPanel.style.transition = 'all 0.3s ease';
|
|
categoriesPanel.style.transition = 'all 0.3s ease';
|
|
|
|
aboutMindmapPanel.style.opacity = '0';
|
|
aboutMindmapPanel.style.transform = 'translateY(10px)';
|
|
|
|
categoriesPanel.style.opacity = '0';
|
|
categoriesPanel.style.transform = 'translateY(10px)';
|
|
|
|
setTimeout(() => {
|
|
aboutMindmapPanel.style.display = 'none';
|
|
categoriesPanel.style.display = 'none';
|
|
|
|
// Beschreibungspanel vorbereiten
|
|
const titleElement = nodeDescriptionPanel.querySelector('[data-node-title]');
|
|
const descriptionElement = nodeDescriptionPanel.querySelector('[data-node-description]');
|
|
|
|
if (titleElement && descriptionElement) {
|
|
titleElement.textContent = node.name || 'Unbenannter Knoten';
|
|
|
|
// Beschreibung setzen oder Standardbeschreibung generieren
|
|
let description = node.description;
|
|
if (!description || description.trim() === '') {
|
|
description = this.generateNodeDescription(node);
|
|
}
|
|
|
|
descriptionElement.textContent = description;
|
|
}
|
|
|
|
// Beschreibungspanel einblenden mit Animation
|
|
nodeDescriptionPanel.style.display = 'block';
|
|
nodeDescriptionPanel.style.opacity = '0';
|
|
nodeDescriptionPanel.style.transform = 'translateY(10px)';
|
|
|
|
setTimeout(() => {
|
|
nodeDescriptionPanel.style.transition = 'all 0.4s ease';
|
|
nodeDescriptionPanel.style.opacity = '1';
|
|
nodeDescriptionPanel.style.transform = 'translateY(0)';
|
|
}, 50);
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
// Neue Methode: Generiert automatisch eine Beschreibung für einen Knoten ohne Beschreibung
|
|
generateNodeDescription(node) {
|
|
const descriptions = {
|
|
"Wissen": "Der zentrale Knotenpunkt der Mindmap, der alle wissenschaftlichen Disziplinen und Wissensgebiete verbindet. Hier finden sich grundlegende Erkenntnisse und Verbindungen zu spezifischeren Fachgebieten.",
|
|
|
|
"Quantenphysik": "Ein Zweig der Physik, der sich mit dem Verhalten und den Interaktionen von Materie und Energie auf der kleinsten Skala beschäftigt. Quantenmechanische Phänomene wie Superposition und Verschränkung bilden die Grundlage für moderne Technologien wie Quantencomputer und -kommunikation.",
|
|
|
|
"Neurowissenschaften": "Interdisziplinäres Forschungsgebiet, das die Struktur, Funktion und Entwicklung des Nervensystems und des Gehirns untersucht. Die Erkenntnisse beeinflussen unser Verständnis von Bewusstsein, Kognition, Verhalten und neurologischen Erkrankungen.",
|
|
|
|
"Künstliche Intelligenz": "Forschungsgebiet der Informatik, das sich mit der Entwicklung von Systemen befasst, die menschliche Intelligenzformen simulieren können. KI umfasst maschinelles Lernen, neuronale Netze und verschiedene Ansätze zur Problemlösung und Mustererkennung.",
|
|
|
|
"Klimaforschung": "Wissenschaftliche Disziplin, die sich mit der Untersuchung des Erdklimas, seinen Veränderungen und den zugrundeliegenden physikalischen Prozessen beschäftigt. Sie liefert wichtige Erkenntnisse zu Klimawandel, Wettermuster und globalen Umweltveränderungen.",
|
|
|
|
"Genetik": "Wissenschaft der Gene, Vererbung und der Variation von Organismen. Moderne genetische Forschung umfasst Genomik, Gentechnologie und das Verständnis der molekularen Grundlagen des Lebens sowie ihrer Anwendungen in Medizin und Biotechnologie.",
|
|
|
|
"Astrophysik": "Zweig der Astronomie, der die physikalischen Eigenschaften und Prozesse von Himmelskörpern und des Universums untersucht. Sie erforscht Phänomene wie Schwarze Löcher, Galaxien, kosmische Strahlung und die Entstehung und Entwicklung des Universums.",
|
|
|
|
"Philosophie": "Disziplin, die sich mit fundamentalen Fragen des Wissens, der Realität und der Existenz auseinandersetzt. Sie umfasst Bereiche wie Metaphysik, Erkenntnistheorie, Ethik und Logik und bildet die Grundlage für kritisches Denken und wissenschaftliche Methodik.",
|
|
|
|
"Wissenschaft": "Systematische Erforschung der Natur und der materiellen Welt durch Beobachtung, Experimente und die Formulierung überprüfbarer Theorien. Sie umfasst Naturwissenschaften, Sozialwissenschaften und angewandte Wissenschaften.",
|
|
|
|
"Technologie": "Anwendung wissenschaftlicher Erkenntnisse für praktische Zwecke. Sie umfasst die Entwicklung von Werkzeugen, Maschinen, Materialien und Prozessen zur Lösung von Problemen und zur Verbesserung der menschlichen Lebensbedingungen.",
|
|
|
|
"Künste": "Ausdruck menschlicher Kreativität und Imagination in verschiedenen Formen wie Malerei, Musik, Literatur, Theater und Film. Die Künste erforschen ästhetische, emotionale und intellektuelle Dimensionen der menschlichen Erfahrung.",
|
|
|
|
"Biologie": "Wissenschaft des Lebens und der lebenden Organismen. Sie umfasst Bereiche wie Molekularbiologie, Evolutionsbiologie, Ökologie und beschäftigt sich mit der Struktur, Funktion, Entwicklung und Evolution lebender Systeme.",
|
|
|
|
"Mathematik": "Wissenschaft der Muster, Strukturen und Beziehungen. Sie ist die Sprache der Naturwissenschaften und bildet die Grundlage für quantitative Analysen, logisches Denken und Problemlösung in allen wissenschaftlichen Disziplinen.",
|
|
|
|
"Psychologie": "Wissenschaftliche Untersuchung des menschlichen Verhaltens und der mentalen Prozesse. Sie erforscht Bereiche wie Kognition, Emotion, Persönlichkeit, soziale Interaktionen und die Behandlung psychischer Störungen.",
|
|
|
|
"Ethik": "Teilgebiet der Philosophie, das sich mit moralischen Prinzipien, Werten und der Frage nach richtigem und falschem Handeln beschäftigt. Sie bildet die Grundlage für moralische Entscheidungsfindung in allen Lebensbereichen."
|
|
};
|
|
|
|
// Verwende vordefinierte Beschreibung, wenn verfügbar
|
|
if (node.name && descriptions[node.name]) {
|
|
return descriptions[node.name];
|
|
}
|
|
|
|
// Generische Beschreibung basierend auf dem Knotentyp
|
|
switch (node.type) {
|
|
case 'category':
|
|
return `Dieser Knoten repräsentiert die Kategorie "${node.name}", die verschiedene verwandte Konzepte und Ideen zusammenfasst. Wählen Sie einen der verbundenen Unterthemen, um mehr Details zu erfahren.`;
|
|
case 'subcategory':
|
|
return `"${node.name}" ist eine Unterkategorie, die spezifische Aspekte eines größeren Themenbereichs beleuchtet. Die verbundenen Knoten zeigen wichtige Konzepte und Ideen innerhalb dieses Bereichs.`;
|
|
default:
|
|
return `Dieser Knoten repräsentiert das Konzept "${node.name}". Erforschen Sie die verbundenen Knoten, um Zusammenhänge und verwandte Ideen zu entdecken.`;
|
|
}
|
|
}
|
|
|
|
// 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; |