844 lines
27 KiB
JavaScript
844 lines
27 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 || 14;
|
|
this.selectedNodeRadius = options.selectedNodeRadius || 20;
|
|
this.linkDistance = options.linkDistance || 150;
|
|
this.chargeStrength = options.chargeStrength || -900;
|
|
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;
|
|
|
|
// Lade die gemerkten Knoten
|
|
this.bookmarkedNodes = this.loadBookmarkedNodes();
|
|
|
|
// Sicherstellen, dass der Container bereit ist
|
|
if (this.container.node()) {
|
|
this.init();
|
|
this.setupDefaultNodes();
|
|
|
|
// Sofortige Datenladung
|
|
window.setTimeout(() => {
|
|
this.loadData();
|
|
}, 100);
|
|
} else {
|
|
console.error('Mindmap-Container nicht gefunden:', containerSelector);
|
|
}
|
|
}
|
|
|
|
// 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: 0 },
|
|
{ id: "philosophy", name: "Philosophie", description: "Philosophisches Denken", thought_count: 0 },
|
|
{ id: "science", name: "Wissenschaft", description: "Wissenschaftliche Erkenntnisse", thought_count: 0 },
|
|
{ id: "technology", name: "Technologie", description: "Technologische Entwicklungen", thought_count: 0 },
|
|
{ id: "arts", name: "Künste", description: "Künstlerische Ausdrucksformen", thought_count: 0 }
|
|
];
|
|
|
|
const defaultLinks = [
|
|
{ source: "root", target: "philosophy" },
|
|
{ source: "root", target: "science" },
|
|
{ source: "root", target: "technology" },
|
|
{ source: "root", target: "arts" }
|
|
];
|
|
|
|
// 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
|
|
this.container.html('');
|
|
|
|
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');
|
|
|
|
// Tooltip initialisieren
|
|
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(20, 20, 40, 0.9)')
|
|
.style('color', '#ffffff')
|
|
.style('border', '1px solid rgba(160, 80, 255, 0.2)')
|
|
.style('border-radius', '6px')
|
|
.style('padding', '8px 12px')
|
|
.style('font-size', '14px')
|
|
.style('max-width', '250px')
|
|
.style('box-shadow', '0 10px 25px rgba(0, 0, 0, 0.5), 0 0 10px rgba(160, 80, 255, 0.2)');
|
|
} else {
|
|
this.tooltipDiv = d3.select('body').select('.node-tooltip');
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
|
|
// Globale Mindmap-Instanz für externe Zugriffe setzen
|
|
window.mindmapInstance = this;
|
|
}
|
|
|
|
handleZoom(transform) {
|
|
this.g.attr('transform', transform);
|
|
this.zoomFactor = transform.k;
|
|
|
|
// Knotengröße anpassen, um bei Zoom lesbar zu bleiben
|
|
if (this.nodeElements) {
|
|
this.nodeElements
|
|
.attr('r', d => (d === this.selectedNode ? this.selectedNodeRadius : this.nodeRadius) / Math.sqrt(transform.k));
|
|
}
|
|
|
|
// Textgröße anpassen
|
|
if (this.textElements) {
|
|
this.textElements
|
|
.style('font-size', `${12 / Math.sqrt(transform.k)}px`);
|
|
}
|
|
}
|
|
|
|
async loadData() {
|
|
try {
|
|
// Ladeindikator anzeigen
|
|
this.showLoading();
|
|
|
|
// Verwende sofort die Standarddaten für eine schnelle erste Anzeige
|
|
this.nodes = [...this.defaultNodes];
|
|
this.links = [...this.defaultLinks];
|
|
|
|
// Visualisierung sofort aktualisieren
|
|
this.isLoading = false;
|
|
this.updateVisualization();
|
|
|
|
// Status auf bereit setzen - don't wait for API
|
|
this.container.attr('data-status', 'ready');
|
|
|
|
// API-Aufruf mit kürzerem Timeout im Hintergrund durchführen
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 Sekunden Timeout
|
|
|
|
const response = await fetch('/api/mindmap', {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Cache-Control': 'no-cache',
|
|
'Pragma': 'no-cache'
|
|
}
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
console.warn(`HTTP Fehler: ${response.status}, versuche erneute Verbindung`);
|
|
|
|
// Bei Verbindungsfehler versuchen, die Verbindung neu herzustellen
|
|
const retryResponse = await fetch('/api/refresh-mindmap', {
|
|
headers: {
|
|
'Cache-Control': 'no-cache',
|
|
'Pragma': 'no-cache'
|
|
}
|
|
});
|
|
|
|
if (!retryResponse.ok) {
|
|
throw new Error(`Retry failed with status: ${retryResponse.status}`);
|
|
}
|
|
|
|
const retryData = await retryResponse.json();
|
|
|
|
if (!retryData.success || !retryData.nodes || retryData.nodes.length === 0) {
|
|
console.warn('Keine Mindmap-Daten nach Neuversuch, verwende weiterhin Standard-Daten.');
|
|
return; // Keep using default data
|
|
}
|
|
|
|
// Flache Liste von Knoten und Verbindungen erstellen
|
|
this.nodes = [];
|
|
this.links = [];
|
|
|
|
// Knoten direkt übernehmen
|
|
retryData.nodes.forEach(node => {
|
|
this.nodes.push({
|
|
id: node.id,
|
|
name: node.name,
|
|
description: node.description || '',
|
|
thought_count: node.thought_count || 0,
|
|
color: this.generateColorFromString(node.name),
|
|
});
|
|
|
|
// Verbindungen hinzufügen
|
|
if (node.connections && node.connections.length > 0) {
|
|
node.connections.forEach(conn => {
|
|
this.links.push({
|
|
source: node.id,
|
|
target: conn.target
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// Visualisierung aktualisieren mit den tatsächlichen Daten
|
|
this.updateVisualization();
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data || !data.nodes || data.nodes.length === 0) {
|
|
console.warn('Keine Mindmap-Daten vorhanden, verwende weiterhin Standard-Daten.');
|
|
return; // Keep using default data
|
|
}
|
|
|
|
// Flache Liste von Knoten und Verbindungen erstellen
|
|
this.nodes = [];
|
|
this.links = [];
|
|
this.processHierarchicalData(data.nodes);
|
|
|
|
// Visualisierung aktualisieren mit den tatsächlichen Daten
|
|
this.updateVisualization();
|
|
|
|
} catch (error) {
|
|
console.warn('Fehler beim Laden der Mindmap-Daten, verwende Standarddaten:', error);
|
|
// Already using default data, no action needed
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Kritischer Fehler bei der Mindmap-Darstellung:', error);
|
|
this.showError('Fehler beim Laden der Mindmap-Daten. Bitte laden Sie die Seite neu.');
|
|
this.container.attr('data-status', 'error');
|
|
}
|
|
}
|
|
|
|
showLoading() {
|
|
// Element nur leeren, wenn es noch kein SVG enthält
|
|
if (!this.container.select('svg').size()) {
|
|
this.container.html(`
|
|
<div class="flex justify-center items-center h-full">
|
|
<div class="text-center">
|
|
<div class="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-primary-400 mx-auto mb-4"></div>
|
|
<p class="text-lg text-white">Mindmap wird geladen...</p>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
}
|
|
|
|
processHierarchicalData(hierarchicalNodes, parentId = null) {
|
|
hierarchicalNodes.forEach(node => {
|
|
// Knoten hinzufügen, wenn noch nicht vorhanden
|
|
if (!this.nodes.find(n => n.id === node.id)) {
|
|
this.nodes.push({
|
|
id: node.id,
|
|
name: node.name,
|
|
description: node.description || '',
|
|
thought_count: node.thought_count || 0,
|
|
color: this.generateColorFromString(node.name),
|
|
});
|
|
}
|
|
|
|
// Verbindung zum Elternknoten hinzufügen
|
|
if (parentId !== null) {
|
|
this.links.push({
|
|
source: parentId,
|
|
target: node.id
|
|
});
|
|
}
|
|
|
|
// Rekursiv für Kindknoten aufrufen
|
|
if (node.children && node.children.length > 0) {
|
|
this.processHierarchicalData(node.children, node.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
generateColorFromString(str) {
|
|
// Erzeugt eine deterministische Farbe basierend auf dem String
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
// Verwende deterministische Farbe aus unserem Farbschema
|
|
const colors = [
|
|
'#4080ff', // primary-400
|
|
'#a040ff', // secondary-400
|
|
'#205cf5', // primary-500
|
|
'#8020f5', // secondary-500
|
|
'#1040e0', // primary-600
|
|
'#6010e0', // secondary-600
|
|
];
|
|
|
|
return colors[Math.abs(hash) % colors.length];
|
|
}
|
|
|
|
updateVisualization() {
|
|
// Starte die Visualisierung nur, wenn nicht mehr im Ladezustand
|
|
if (this.isLoading) return;
|
|
|
|
// Container leeren, wenn Diagramm neu erstellt wird
|
|
if (!this.svg) {
|
|
this.container.html('');
|
|
this.init();
|
|
}
|
|
|
|
// Performance-Optimierung: Deaktiviere Transition während des Datenladens
|
|
const useTransitions = false;
|
|
|
|
// Links (Edges) erstellen
|
|
this.linkElements = this.g.selectAll('.link')
|
|
.data(this.links)
|
|
.join(
|
|
enter => enter.append('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke', '#ffffff30')
|
|
.attr('stroke-width', 2)
|
|
.attr('stroke-dasharray', d => d.relation_type === 'contradicts' ? '5,5' : null)
|
|
.attr('marker-end', d => d.relation_type === 'builds_upon' ? 'url(#arrowhead)' : null),
|
|
update => update
|
|
.attr('stroke', '#ffffff30')
|
|
.attr('stroke-dasharray', d => d.relation_type === 'contradicts' ? '5,5' : null)
|
|
.attr('marker-end', d => d.relation_type === 'builds_upon' ? 'url(#arrowhead)' : null),
|
|
exit => exit.remove()
|
|
);
|
|
|
|
// Pfeilspitze für gerichtete Beziehungen hinzufügen (falls noch nicht vorhanden)
|
|
if (!this.svg.select('defs').node()) {
|
|
const defs = this.svg.append('defs');
|
|
defs.append('marker')
|
|
.attr('id', 'arrowhead')
|
|
.attr('viewBox', '0 -5 10 10')
|
|
.attr('refX', 20)
|
|
.attr('refY', 0)
|
|
.attr('orient', 'auto')
|
|
.attr('markerWidth', 6)
|
|
.attr('markerHeight', 6)
|
|
.append('path')
|
|
.attr('d', 'M0,-5L10,0L0,5')
|
|
.attr('fill', '#ffffff50');
|
|
}
|
|
|
|
// Simplified Effekte definieren, falls noch nicht vorhanden
|
|
if (!this.svg.select('#glow').node()) {
|
|
const defs = this.svg.select('defs').size() ? this.svg.select('defs') : this.svg.append('defs');
|
|
|
|
// Glow-Effekt für Knoten
|
|
const filter = defs.append('filter')
|
|
.attr('id', 'glow')
|
|
.attr('x', '-50%')
|
|
.attr('y', '-50%')
|
|
.attr('width', '200%')
|
|
.attr('height', '200%');
|
|
|
|
filter.append('feGaussianBlur')
|
|
.attr('stdDeviation', '1')
|
|
.attr('result', 'blur');
|
|
|
|
filter.append('feComposite')
|
|
.attr('in', 'SourceGraphic')
|
|
.attr('in2', 'blur')
|
|
.attr('operator', 'over');
|
|
|
|
// Blur-Effekt für Schatten
|
|
const blurFilter = defs.append('filter')
|
|
.attr('id', 'blur')
|
|
.attr('x', '-50%')
|
|
.attr('y', '-50%')
|
|
.attr('width', '200%')
|
|
.attr('height', '200%');
|
|
|
|
blurFilter.append('feGaussianBlur')
|
|
.attr('stdDeviation', '1');
|
|
}
|
|
|
|
// Knoten-Gruppe erstellen/aktualisieren
|
|
const nodeGroups = this.g.selectAll('.node-group')
|
|
.data(this.nodes)
|
|
.join(
|
|
enter => {
|
|
const group = enter.append('g')
|
|
.attr('class', 'node-group')
|
|
.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)));
|
|
|
|
// Hintergrundschatten für besseren Kontrast
|
|
group.append('circle')
|
|
.attr('class', 'node-shadow')
|
|
.attr('r', d => this.nodeRadius * 1.2)
|
|
.attr('fill', 'rgba(0, 0, 0, 0.3)')
|
|
.attr('filter', 'url(#blur)');
|
|
|
|
// Kreis für jeden Knoten
|
|
group.append('circle')
|
|
.attr('class', d => `node ${this.isNodeBookmarked(d.id) ? 'bookmarked' : ''}`)
|
|
.attr('r', this.nodeRadius)
|
|
.attr('fill', d => d.color || this.generateColorFromString(d.name))
|
|
.attr('stroke', d => this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff50')
|
|
.attr('stroke-width', d => this.isNodeBookmarked(d.id) ? 3 : 2)
|
|
.attr('filter', 'url(#glow)');
|
|
|
|
// Text-Label mit besserem Kontrast
|
|
group.append('text')
|
|
.attr('class', 'node-label')
|
|
.attr('dy', '0.35em')
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', '#ffffff')
|
|
.attr('stroke', 'rgba(0, 0, 0, 0.4)')
|
|
.attr('stroke-width', '0.7px')
|
|
.attr('paint-order', 'stroke')
|
|
.style('font-size', '12px')
|
|
.style('font-weight', '500')
|
|
.style('pointer-events', 'none')
|
|
.text(d => d.name.length > 12 ? d.name.slice(0, 10) + '...' : d.name);
|
|
|
|
// Interaktivität hinzufügen
|
|
group
|
|
.on('mouseover', (event, d) => this.nodeMouseover(event, d))
|
|
.on('mouseout', (event, d) => this.nodeMouseout(event, d))
|
|
.on('click', (event, d) => this.nodeClicked(event, d));
|
|
|
|
return group;
|
|
},
|
|
update => {
|
|
// Knoten aktualisieren
|
|
update.select('.node')
|
|
.attr('class', d => `node ${this.isNodeBookmarked(d.id) ? 'bookmarked' : ''}`)
|
|
.attr('fill', d => d.color || this.generateColorFromString(d.name))
|
|
.attr('stroke', d => this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff50')
|
|
.attr('stroke-width', d => this.isNodeBookmarked(d.id) ? 3 : 2);
|
|
|
|
// Text aktualisieren
|
|
update.select('.node-label')
|
|
.text(d => d.name.length > 12 ? d.name.slice(0, 10) + '...' : d.name);
|
|
|
|
return update;
|
|
},
|
|
exit => exit.remove()
|
|
);
|
|
|
|
// Einzelne Elemente für direkten Zugriff speichern
|
|
this.nodeElements = this.g.selectAll('.node');
|
|
this.textElements = this.g.selectAll('.node-label');
|
|
|
|
// Performance-Optimierung: Weniger Simulationsschritte für schnellere Stabilisierung
|
|
this.simulation
|
|
.nodes(this.nodes)
|
|
.on('tick', () => this.ticked())
|
|
.alpha(0.3) // Reduzierter Wert für schnellere Stabilisierung
|
|
.alphaDecay(0.05); // Erhöhter Wert für schnellere Stabilisierung
|
|
|
|
this.simulation.force('link')
|
|
.links(this.links);
|
|
|
|
// Simulation neu starten
|
|
this.simulation.restart();
|
|
|
|
// Update connection counts
|
|
this.updateConnectionCounts();
|
|
}
|
|
|
|
ticked() {
|
|
// Linienpositionen aktualisieren
|
|
this.linkElements
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
// Knotenpositionen aktualisieren
|
|
this.g.selectAll('.node-group')
|
|
.attr('transform', d => `translate(${d.x}, ${d.y})`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
nodeMouseover(event, d) {
|
|
this.mouseoverNode = d;
|
|
|
|
// Tooltip anzeigen
|
|
if (this.tooltipEnabled) {
|
|
const isBookmarked = this.isNodeBookmarked(d.id);
|
|
const tooltipContent = `
|
|
<div class="p-2">
|
|
<strong>${d.name}</strong>
|
|
${d.description ? `<p class="text-sm text-gray-200 mt-1">${d.description}</p>` : ''}
|
|
<div class="text-xs text-gray-300 mt-1">
|
|
Gedanken: ${d.thought_count}
|
|
</div>
|
|
<div class="mt-2">
|
|
<button id="bookmark-button" class="px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-white"
|
|
data-nodeid="${d.id}">
|
|
${isBookmarked ? '<i class="fas fa-bookmark mr-1"></i> Gemerkt' : '<i class="far fa-bookmark mr-1"></i> Merken'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.tooltipDiv
|
|
.html(tooltipContent)
|
|
.style('left', (event.pageX + 10) + 'px')
|
|
.style('top', (event.pageY - 10) + 'px')
|
|
.transition()
|
|
.duration(200)
|
|
.style('opacity', 1);
|
|
|
|
// Event-Listener für den Bookmark-Button hinzufügen
|
|
document.getElementById('bookmark-button').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const nodeId = e.currentTarget.getAttribute('data-nodeid');
|
|
const isNowBookmarked = this.toggleBookmark(nodeId);
|
|
|
|
// Button-Text aktualisieren
|
|
if (isNowBookmarked) {
|
|
e.currentTarget.innerHTML = '<i class="fas fa-bookmark mr-1"></i> Gemerkt';
|
|
} else {
|
|
e.currentTarget.innerHTML = '<i class="far fa-bookmark mr-1"></i> Merken';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Knoten visuell hervorheben
|
|
d3.select(event.currentTarget).select('circle')
|
|
.transition()
|
|
.duration(200)
|
|
.attr('r', this.nodeRadius * 1.2)
|
|
.attr('stroke', this.isNodeBookmarked(d.id) ? '#FFD700' : '#ffffff');
|
|
}
|
|
|
|
nodeMouseout(event, d) {
|
|
this.mouseoverNode = null;
|
|
|
|
// Tooltip ausblenden
|
|
if (this.tooltipEnabled) {
|
|
this.tooltipDiv
|
|
.transition()
|
|
.duration(200)
|
|
.style('opacity', 0);
|
|
}
|
|
|
|
// Knoten-Stil zurücksetzen, wenn nicht ausgewählt
|
|
const nodeElement = d3.select(event.currentTarget).select('circle');
|
|
if (d !== this.selectedNode) {
|
|
const isBookmarked = this.isNodeBookmarked(d.id);
|
|
nodeElement
|
|
.transition()
|
|
.duration(200)
|
|
.attr('r', this.nodeRadius)
|
|
.attr('stroke', isBookmarked ? '#FFD700' : '#ffffff50')
|
|
.attr('stroke-width', isBookmarked ? 3 : 2);
|
|
}
|
|
}
|
|
|
|
nodeClicked(event, d) {
|
|
// Frühere Auswahl zurücksetzen
|
|
if (this.selectedNode && this.selectedNode !== d) {
|
|
this.g.selectAll('.node')
|
|
.filter(n => n === this.selectedNode)
|
|
.transition()
|
|
.duration(200)
|
|
.attr('r', this.nodeRadius)
|
|
.attr('stroke', '#ffffff50');
|
|
}
|
|
|
|
// Neue Auswahl hervorheben
|
|
if (this.selectedNode !== d) {
|
|
this.selectedNode = d;
|
|
d3.select(event.currentTarget).select('circle')
|
|
.transition()
|
|
.duration(200)
|
|
.attr('r', this.selectedNodeRadius)
|
|
.attr('stroke', '#ffffff');
|
|
}
|
|
|
|
// Callback mit Node-Daten aufrufen
|
|
this.onNodeClick(d);
|
|
}
|
|
|
|
showError(message) {
|
|
this.container.html(`
|
|
<div class="w-full text-center p-6">
|
|
<div class="mb-4 text-red-500">
|
|
<i class="fas fa-exclamation-triangle text-4xl"></i>
|
|
</div>
|
|
<p class="text-lg text-gray-200">${message}</p>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
// Fokussiert die Ansicht auf einen bestimmten Knoten
|
|
focusNode(nodeId) {
|
|
const node = this.nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
|
|
// Simuliere einen Klick auf den Knoten
|
|
const nodeElement = this.g.selectAll('.node-group')
|
|
.filter(d => d.id === nodeId);
|
|
|
|
nodeElement.dispatch('click');
|
|
|
|
// Zentriere den Knoten in der Ansicht
|
|
const transform = d3.zoomIdentity
|
|
.translate(this.width / 2, this.height / 2)
|
|
.scale(1.2)
|
|
.translate(-node.x, -node.y);
|
|
|
|
this.svg.transition()
|
|
.duration(750)
|
|
.call(
|
|
d3.zoom().transform,
|
|
transform
|
|
);
|
|
}
|
|
|
|
// Filtert die Mindmap basierend auf einem Suchbegriff
|
|
filterBySearchTerm(searchTerm) {
|
|
if (!searchTerm || searchTerm.trim() === '') {
|
|
// Alle Knoten anzeigen
|
|
this.g.selectAll('.node-group')
|
|
.style('opacity', 1)
|
|
.style('pointer-events', 'all');
|
|
|
|
this.g.selectAll('.link')
|
|
.style('opacity', 1);
|
|
|
|
return;
|
|
}
|
|
|
|
const searchLower = searchTerm.toLowerCase();
|
|
const matchingNodes = this.nodes.filter(node =>
|
|
node.name.toLowerCase().includes(searchLower) ||
|
|
(node.description && node.description.toLowerCase().includes(searchLower))
|
|
);
|
|
|
|
const matchingNodeIds = new Set(matchingNodes.map(n => n.id));
|
|
|
|
// Passende Knoten hervorheben, andere ausblenden
|
|
this.g.selectAll('.node-group')
|
|
.style('opacity', d => matchingNodeIds.has(d.id) ? 1 : 0.2)
|
|
.style('pointer-events', d => matchingNodeIds.has(d.id) ? 'all' : 'none');
|
|
|
|
// Verbindungen zwischen passenden Knoten hervorheben
|
|
this.g.selectAll('.link')
|
|
.style('opacity', d =>
|
|
matchingNodeIds.has(d.source.id) && matchingNodeIds.has(d.target.id) ? 1 : 0.1
|
|
);
|
|
|
|
// Auf den ersten passenden Knoten fokussieren, wenn vorhanden
|
|
if (matchingNodes.length > 0) {
|
|
this.focusNode(matchingNodes[0].id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the thought_count property for each node based on existing connections
|
|
*/
|
|
updateConnectionCounts() {
|
|
// Reset all counts first
|
|
this.nodes.forEach(node => {
|
|
// Initialize thought_count if it doesn't exist
|
|
if (typeof node.thought_count !== 'number') {
|
|
node.thought_count = 0;
|
|
}
|
|
|
|
// Count connections for this node
|
|
const connectedNodes = this.getConnectedNodes(node);
|
|
node.thought_count = connectedNodes.length;
|
|
});
|
|
|
|
// Update UI to show counts
|
|
this.updateNodeLabels();
|
|
}
|
|
|
|
/**
|
|
* Updates the visual representation of node labels to include connection counts
|
|
*/
|
|
updateNodeLabels() {
|
|
if (!this.textElements) return;
|
|
|
|
this.textElements.text(d => {
|
|
if (d.thought_count > 0) {
|
|
return `${d.name} (${d.thought_count})`;
|
|
}
|
|
return d.name;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds a new connection between nodes and updates the counts
|
|
*/
|
|
addConnection(sourceNode, targetNode) {
|
|
if (!sourceNode || !targetNode) return false;
|
|
|
|
// Check if connection already exists
|
|
if (this.isConnected(sourceNode, targetNode)) return false;
|
|
|
|
// Add new connection
|
|
this.links.push({
|
|
source: sourceNode.id,
|
|
target: targetNode.id
|
|
});
|
|
|
|
// Update counts
|
|
this.updateConnectionCounts();
|
|
|
|
// Update visualization
|
|
this.updateVisualization();
|
|
|
|
return true;
|
|
}
|
|
|
|
// Lädt gemerkete Knoten aus dem LocalStorage
|
|
loadBookmarkedNodes() {
|
|
try {
|
|
const bookmarked = localStorage.getItem('bookmarkedNodes');
|
|
return bookmarked ? JSON.parse(bookmarked) : [];
|
|
} catch (error) {
|
|
console.error('Fehler beim Laden der gemerkten Knoten:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Speichert gemerkete Knoten im LocalStorage
|
|
saveBookmarkedNodes() {
|
|
try {
|
|
localStorage.setItem('bookmarkedNodes', JSON.stringify(this.bookmarkedNodes));
|
|
} catch (error) {
|
|
console.error('Fehler beim Speichern der gemerkten Knoten:', error);
|
|
}
|
|
}
|
|
|
|
// Prüft, ob ein Knoten gemerkt ist
|
|
isNodeBookmarked(nodeId) {
|
|
return this.bookmarkedNodes.includes(nodeId);
|
|
}
|
|
|
|
// Merkt einen Knoten oder hebt die Markierung auf
|
|
toggleBookmark(nodeId) {
|
|
const index = this.bookmarkedNodes.indexOf(nodeId);
|
|
if (index === -1) {
|
|
// Node hinzufügen
|
|
this.bookmarkedNodes.push(nodeId);
|
|
this.updateNodeAppearance(nodeId, true);
|
|
} else {
|
|
// Node entfernen
|
|
this.bookmarkedNodes.splice(index, 1);
|
|
this.updateNodeAppearance(nodeId, false);
|
|
}
|
|
|
|
// Änderungen speichern
|
|
this.saveBookmarkedNodes();
|
|
|
|
// Event auslösen für andere Komponenten
|
|
const event = new CustomEvent('nodeBookmarkToggled', {
|
|
detail: {
|
|
nodeId: nodeId,
|
|
isBookmarked: index === -1
|
|
}
|
|
});
|
|
document.dispatchEvent(event);
|
|
|
|
return index === -1; // true wenn jetzt gemerkt, false wenn Markierung aufgehoben
|
|
}
|
|
|
|
// Aktualisiert das Aussehen eines Knotens basierend auf Bookmark-Status
|
|
updateNodeAppearance(nodeId, isBookmarked) {
|
|
this.g.selectAll('.node-group')
|
|
.filter(d => d.id === nodeId)
|
|
.select('.node')
|
|
.classed('bookmarked', isBookmarked)
|
|
.attr('stroke', isBookmarked ? '#FFD700' : '#ffffff50')
|
|
.attr('stroke-width', isBookmarked ? 3 : 2);
|
|
}
|
|
|
|
// Aktualisiert das Aussehen aller gemerkten Knoten
|
|
updateAllBookmarkedNodes() {
|
|
this.g.selectAll('.node-group')
|
|
.each((d) => {
|
|
const isBookmarked = this.isNodeBookmarked(d.id);
|
|
this.updateNodeAppearance(d.id, isBookmarked);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gibt alle direkt verbundenen Knoten eines Knotens zurück
|
|
* @param {Object} node - Der Knoten, für den die Verbindungen gesucht werden
|
|
* @returns {Array} Array der verbundenen Knotenobjekte
|
|
*/
|
|
getConnectedNodes(node) {
|
|
if (!node || !this.links || !this.nodes) return [];
|
|
const nodeId = node.id;
|
|
const connectedIds = new Set();
|
|
this.links.forEach(link => {
|
|
if (link.source === nodeId || (link.source && link.source.id === nodeId)) {
|
|
connectedIds.add(link.target.id ? link.target.id : link.target);
|
|
}
|
|
if (link.target === nodeId || (link.target && link.target.id === nodeId)) {
|
|
connectedIds.add(link.source.id ? link.source.id : link.source);
|
|
}
|
|
});
|
|
return this.nodes.filter(n => connectedIds.has(n.id));
|
|
}
|
|
}
|
|
|
|
// Exportiere die Klasse für die Verwendung in anderen Modulen
|
|
window.MindMapVisualization = MindMapVisualization;
|