546 lines
15 KiB
JavaScript
546 lines
15 KiB
JavaScript
/**
|
|
* Mindmap-Visualisierungsmodul mit Cytoscape.js
|
|
* Erstellt eine interaktive Mindmap-Visualisierung
|
|
*/
|
|
|
|
// Globales MindMap-Objekt erstellen, falls es noch nicht existiert
|
|
if (!window.MindMap) {
|
|
window.MindMap = {};
|
|
}
|
|
|
|
// MindMap Klasse hinzufügen
|
|
window.MindMap.Visualization = class MindMapVisualization {
|
|
/**
|
|
* Konstruktor für die MindMap-Visualisierung
|
|
* @param {string} containerId - ID des HTML-Containers für die Visualisierung
|
|
* @param {object} options - Optionen für die Visualisierung
|
|
*/
|
|
constructor(containerId, options = {}) {
|
|
this.containerId = containerId;
|
|
this.container = document.getElementById(containerId.replace('#', ''));
|
|
|
|
// Optionen mit Standardwerten
|
|
this.options = {
|
|
layout: options.layout || 'cose',
|
|
darkMode: options.darkMode || document.documentElement.classList.contains('dark'),
|
|
nodeClickCallback: options.nodeClickCallback || null,
|
|
height: options.height || 600,
|
|
...options
|
|
};
|
|
|
|
// Cytoscape-Instanz
|
|
this.cy = null;
|
|
|
|
// Vorbereitende Prüfungen
|
|
if (!this.container) {
|
|
console.error(`Container mit ID ${containerId} nicht gefunden`);
|
|
return;
|
|
}
|
|
|
|
// Cytoscape-Bibliothek prüfen
|
|
if (typeof cytoscape === 'undefined') {
|
|
this._loadCytoscapeLibrary()
|
|
.then(() => this.initialize())
|
|
.catch(error => {
|
|
console.error('Cytoscape.js konnte nicht geladen werden:', error);
|
|
this._showError('Cytoscape.js konnte nicht geladen werden. Bitte laden Sie die Seite neu.');
|
|
});
|
|
} else {
|
|
this.initialize();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lädt die Cytoscape-Bibliothek dynamisch
|
|
* @returns {Promise} Promise, das nach dem Laden erfüllt wird
|
|
*/
|
|
_loadCytoscapeLibrary() {
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.26.0/cytoscape.min.js';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialisiert die Cytoscape-Instanz
|
|
*/
|
|
initialize() {
|
|
// Container-Dimensionen festlegen
|
|
this.container.style.height = `${this.options.height}px`;
|
|
|
|
// Cytoscape-Instanz erstellen
|
|
this.cy = cytoscape({
|
|
container: this.container,
|
|
elements: [],
|
|
style: this._createStylesheet(),
|
|
layout: this._getLayoutOptions(),
|
|
wheelSensitivity: 0.2,
|
|
minZoom: 0.1,
|
|
maxZoom: 3,
|
|
boxSelectionEnabled: false
|
|
});
|
|
|
|
// Event-Listener für Knoten-Klicks
|
|
this.cy.on('tap', 'node', event => {
|
|
const node = event.target;
|
|
|
|
// Alle Knoten zurücksetzen
|
|
this.cy.nodes().removeClass('selected');
|
|
|
|
// Ausgewählten Knoten markieren
|
|
node.addClass('selected');
|
|
|
|
// Callback aufrufen, falls definiert
|
|
if (this.options.nodeClickCallback) {
|
|
this.options.nodeClickCallback(node.data());
|
|
}
|
|
});
|
|
|
|
// Event-Listener für Hintergrund-Klicks (Selektion aufheben)
|
|
this.cy.on('tap', event => {
|
|
if (event.target === this.cy) {
|
|
this.cy.nodes().removeClass('selected');
|
|
}
|
|
});
|
|
|
|
// Dark Mode Änderungen überwachen
|
|
document.addEventListener('darkModeToggled', event => {
|
|
this.updateDarkMode(event.detail.isDark);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Erstellt das Stylesheet für Cytoscape
|
|
* @returns {Array} Array mit Stilregeln
|
|
*/
|
|
_createStylesheet() {
|
|
const isDarkMode = this.options.darkMode;
|
|
|
|
return [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'background-color': 'data(color)',
|
|
'label': 'data(name)',
|
|
'width': 30,
|
|
'height': 30,
|
|
'font-size': 12,
|
|
'text-valign': 'bottom',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 8,
|
|
'color': isDarkMode ? '#f1f5f9' : '#334155',
|
|
'text-background-color': isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(241, 245, 249, 0.8)',
|
|
'text-background-opacity': 0.8,
|
|
'text-background-padding': '2px',
|
|
'text-background-shape': 'roundrectangle',
|
|
'text-wrap': 'ellipsis',
|
|
'text-max-width': '100px'
|
|
}
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 2,
|
|
'line-color': isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)',
|
|
'target-arrow-color': isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)',
|
|
'curve-style': 'bezier'
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.selected',
|
|
style: {
|
|
'background-color': 'data(color)',
|
|
'border-width': 3,
|
|
'border-color': '#8b5cf6',
|
|
'width': 40,
|
|
'height': 40,
|
|
'font-size': 14,
|
|
'font-weight': 'bold',
|
|
'text-background-color': '#8b5cf6',
|
|
'text-background-opacity': 0.9
|
|
}
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ermittelt die Layout-Optionen basierend auf dem gewählten Layout
|
|
* @returns {object} Layout-Optionen für Cytoscape
|
|
*/
|
|
_getLayoutOptions() {
|
|
const layouts = {
|
|
'cose': {
|
|
name: 'cose',
|
|
animate: true,
|
|
animationDuration: 800,
|
|
nodeDimensionsIncludeLabels: true,
|
|
refresh: 30,
|
|
randomize: true,
|
|
componentSpacing: 100,
|
|
nodeRepulsion: 8000,
|
|
nodeOverlap: 20,
|
|
idealEdgeLength: 200,
|
|
edgeElasticity: 100,
|
|
nestingFactor: 1.2,
|
|
gravity: 80,
|
|
fit: true,
|
|
padding: 30
|
|
},
|
|
'circle': {
|
|
name: 'circle',
|
|
fit: true,
|
|
padding: 50,
|
|
radius: 200,
|
|
startAngle: 3 / 2 * Math.PI,
|
|
sweep: 2 * Math.PI,
|
|
clockwise: true,
|
|
sort: (a, b) => a.data('name').localeCompare(b.data('name'))
|
|
},
|
|
'concentric': {
|
|
name: 'concentric',
|
|
fit: true,
|
|
padding: 50,
|
|
concentric: node => node.data('connections') || 1,
|
|
levelWidth: () => 1,
|
|
minNodeSpacing: 50
|
|
}
|
|
};
|
|
|
|
return layouts[this.options.layout] || layouts['cose'];
|
|
}
|
|
|
|
/**
|
|
* Zeigt eine Fehlermeldung im Container an
|
|
* @param {string} message - Die anzuzeigende Fehlermeldung
|
|
*/
|
|
_showError(message) {
|
|
this.container.innerHTML = `
|
|
<div class="p-8 text-center bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
<i class="fa-solid fa-triangle-exclamation text-4xl text-red-500 mb-4"></i>
|
|
<p class="text-lg text-red-600 dark:text-red-400">${message}</p>
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Bitte laden Sie die Seite neu oder kontaktieren Sie den Support.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Zeigt eine Ladeanimation im Container an
|
|
*/
|
|
showLoading() {
|
|
this.container.innerHTML = `
|
|
<div class="flex justify-center items-center h-full">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Lädt Daten aus der API und rendert sie
|
|
*/
|
|
loadData() {
|
|
// Ladeanimation anzeigen
|
|
this.showLoading();
|
|
|
|
// Versuche, Daten vom Server zu laden
|
|
fetch('/api/mindmap')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('Netzwerkfehler beim Laden der Mindmap-Daten');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
this.renderData(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Fehler beim Laden der Mindmap-Daten:', error);
|
|
|
|
// Verwende Standarddaten als Fallback
|
|
console.log('Verwende Standarddaten als Fallback...');
|
|
const defaultData = this._generateDefaultData();
|
|
this.renderData(defaultData);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generiert Standarddaten für die Mindmap als Fallback
|
|
* @returns {object} Standarddaten für die Mindmap
|
|
*/
|
|
_generateDefaultData() {
|
|
return {
|
|
nodes: [
|
|
{ id: 'root', name: 'Wissen', description: 'Zentrale Wissensbasis', category: 'Zentral', color_code: '#4299E1' },
|
|
{ id: 'philosophy', name: 'Philosophie', description: 'Philosophisches Denken', category: 'Philosophie', color_code: '#9F7AEA', parent_id: 'root' },
|
|
{ id: 'science', name: 'Wissenschaft', description: 'Wissenschaftliche Erkenntnisse', category: 'Wissenschaft', color_code: '#48BB78', parent_id: 'root' },
|
|
{ id: 'technology', name: 'Technologie', description: 'Technologische Entwicklungen', category: 'Technologie', color_code: '#ED8936', parent_id: 'root' },
|
|
{ id: 'arts', name: 'Künste', description: 'Künstlerische Ausdrucksformen', category: 'Künste', color_code: '#ED64A6', parent_id: 'root' },
|
|
{ id: 'psychology', name: 'Psychologie', description: 'Menschliches Verhalten und Geist', category: 'Psychologie', color_code: '#4299E1', parent_id: 'root' }
|
|
]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rendert die Daten in der Cytoscape-Instanz
|
|
* @param {object} data - Daten für die Mindmap
|
|
*/
|
|
renderData(data) {
|
|
if (!this.cy) {
|
|
console.error('Cytoscape-Instanz nicht initialisiert');
|
|
return;
|
|
}
|
|
|
|
// Konvertiere die Daten in das Cytoscape-Format
|
|
const elements = this._convertToCytoscapeFormat(data);
|
|
|
|
// Aktualisiere die Elemente
|
|
this.cy.elements().remove();
|
|
this.cy.add(elements);
|
|
|
|
// Layout neu berechnen und anwenden
|
|
const layout = this.cy.layout(this._getLayoutOptions());
|
|
layout.run();
|
|
|
|
// Zentriere und passe die Ansicht an
|
|
setTimeout(() => {
|
|
this.fitView();
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Konvertiert die Daten in das Cytoscape-Format
|
|
* @param {object} data - Daten für die Mindmap
|
|
* @returns {Array} Cytoscape-Elemente
|
|
*/
|
|
_convertToCytoscapeFormat(data) {
|
|
const elements = [];
|
|
|
|
// Knoten hinzufügen
|
|
if (data.nodes && data.nodes.length > 0) {
|
|
data.nodes.forEach(node => {
|
|
elements.push({
|
|
group: 'nodes',
|
|
data: {
|
|
id: String(node.id),
|
|
name: node.name,
|
|
description: node.description || 'Keine Beschreibung verfügbar',
|
|
category: node.category || 'Allgemein',
|
|
color: node.color_code || this._getRandomColor(),
|
|
connections: 0
|
|
}
|
|
});
|
|
|
|
// Kante zum Elternknoten hinzufügen (falls vorhanden)
|
|
if (node.parent_id) {
|
|
elements.push({
|
|
group: 'edges',
|
|
data: {
|
|
id: `edge-${node.parent_id}-${node.id}`,
|
|
source: String(node.parent_id),
|
|
target: String(node.id)
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Zusätzliche Kanten zwischen Knoten hinzufügen (falls in den Daten vorhanden)
|
|
if (data.edges && data.edges.length > 0) {
|
|
data.edges.forEach(edge => {
|
|
elements.push({
|
|
group: 'edges',
|
|
data: {
|
|
id: `edge-${edge.source}-${edge.target}`,
|
|
source: String(edge.source),
|
|
target: String(edge.target)
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
return elements;
|
|
}
|
|
|
|
/**
|
|
* Generiert eine zufällige Farbe
|
|
* @returns {string} Zufällige Farbe als HEX-Code
|
|
*/
|
|
_getRandomColor() {
|
|
const colors = [
|
|
'#4299E1', // Blau
|
|
'#9F7AEA', // Lila
|
|
'#48BB78', // Grün
|
|
'#ED8936', // Orange
|
|
'#ED64A6', // Pink
|
|
'#F56565' // Rot
|
|
];
|
|
return colors[Math.floor(Math.random() * colors.length)];
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert den Dark Mode für die Visualisierung
|
|
* @param {boolean} isDark - Ob der Dark Mode aktiviert ist
|
|
*/
|
|
updateDarkMode(isDark) {
|
|
if (!this.cy) return;
|
|
|
|
this.options.darkMode = isDark;
|
|
|
|
// Farben aktualisieren
|
|
const textColor = isDark ? '#f1f5f9' : '#334155';
|
|
const textBgColor = isDark ? 'rgba(30, 41, 59, 0.8)' : 'rgba(241, 245, 249, 0.8)';
|
|
const edgeColor = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(30, 41, 59, 0.15)';
|
|
|
|
// Stile aktualisieren
|
|
this.cy.style()
|
|
.selector('node')
|
|
.style({
|
|
'color': textColor,
|
|
'text-background-color': textBgColor
|
|
})
|
|
.selector('edge')
|
|
.style({
|
|
'line-color': edgeColor,
|
|
'target-arrow-color': edgeColor
|
|
})
|
|
.update();
|
|
}
|
|
|
|
/**
|
|
* Passt die Ansicht an die Mindmap an
|
|
*/
|
|
fitView() {
|
|
if (!this.cy) return;
|
|
|
|
this.cy.fit();
|
|
this.cy.center();
|
|
}
|
|
|
|
/**
|
|
* Zoomt in die Mindmap hinein
|
|
*/
|
|
zoomIn() {
|
|
if (!this.cy) return;
|
|
|
|
const zoom = this.cy.zoom() * 1.2;
|
|
this.cy.zoom({
|
|
level: zoom,
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Zoomt aus der Mindmap heraus
|
|
*/
|
|
zoomOut() {
|
|
if (!this.cy) return;
|
|
|
|
const zoom = this.cy.zoom() / 1.2;
|
|
this.cy.zoom({
|
|
level: zoom,
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Setzt den Zoom zurück
|
|
*/
|
|
resetZoom() {
|
|
if (!this.cy) return;
|
|
|
|
this.cy.zoom(1);
|
|
this.cy.center();
|
|
}
|
|
|
|
/**
|
|
* Berechnet das Layout neu
|
|
*/
|
|
relayout() {
|
|
if (!this.cy) return;
|
|
|
|
const layout = this.cy.layout(this._getLayoutOptions());
|
|
layout.run();
|
|
}
|
|
|
|
/**
|
|
* Ändert das Layout
|
|
* @param {string} layoutName - Name des Layouts
|
|
*/
|
|
changeLayout(layoutName) {
|
|
if (!this.cy) return;
|
|
|
|
this.options.layout = layoutName;
|
|
const layout = this.cy.layout(this._getLayoutOptions());
|
|
layout.run();
|
|
}
|
|
|
|
/**
|
|
* Fokussiert einen Knoten
|
|
* @param {string} nodeId - ID des zu fokussierenden Knotens
|
|
*/
|
|
focusNode(nodeId) {
|
|
if (!this.cy) return;
|
|
|
|
const node = this.cy.getElementById(nodeId);
|
|
if (node.length > 0) {
|
|
this.cy.nodes().removeClass('selected');
|
|
node.addClass('selected');
|
|
this.cy.center(node);
|
|
|
|
// Callback aufrufen, falls definiert
|
|
if (this.options.nodeClickCallback) {
|
|
this.options.nodeClickCallback(node.data());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sucht nach Knoten basierend auf einem Suchbegriff
|
|
* @param {string} searchTerm - Suchbegriff
|
|
*/
|
|
search(searchTerm) {
|
|
if (!this.cy || !searchTerm) return;
|
|
|
|
const term = searchTerm.toLowerCase();
|
|
let found = false;
|
|
|
|
// Alle Knoten durchsuchen
|
|
this.cy.nodes().forEach(node => {
|
|
const name = node.data('name').toLowerCase();
|
|
const category = node.data('category').toLowerCase();
|
|
const description = node.data('description').toLowerCase();
|
|
|
|
// Prüfen, ob der Suchbegriff enthalten ist
|
|
if (name.includes(term) || category.includes(term) || description.includes(term)) {
|
|
found = true;
|
|
this.cy.nodes().removeClass('selected');
|
|
node.addClass('selected');
|
|
this.cy.center(node);
|
|
|
|
// Callback nur für den ersten gefundenen Knoten aufrufen
|
|
if (found && this.options.nodeClickCallback) {
|
|
this.options.nodeClickCallback(node.data());
|
|
}
|
|
|
|
return false; // Schleife abbrechen nach dem ersten Fund
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gibt die Daten des ausgewählten Knotens zurück
|
|
* @returns {object|null} Daten des ausgewählten Knotens oder null
|
|
*/
|
|
getSelectedNodeData() {
|
|
if (!this.cy) return null;
|
|
|
|
const selectedNodes = this.cy.nodes('.selected');
|
|
if (selectedNodes.length > 0) {
|
|
return selectedNodes[0].data();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
};
|