/** * Mindmap.js - Interaktive Mind-Map Implementierung * - Cytoscape.js für Graph-Rendering * - Fetch API für REST-Zugriffe * - Socket.IO für Echtzeit-Synchronisation */ (async () => { /* 1. Initialisierung und Grundkonfiguration */ const cy = cytoscape({ container: document.getElementById('cy'), style: [ { selector: 'node', style: { 'label': 'data(name)', 'text-valign': 'center', 'color': '#fff', 'background-color': 'data(color)', 'width': 45, 'height': 45, 'font-size': 11, 'text-outline-width': 1, 'text-outline-color': '#000', 'text-outline-opacity': 0.5, 'text-wrap': 'wrap', 'text-max-width': 80 } }, { selector: 'node[icon]', style: { 'background-image': function(ele) { return `static/img/icons/${ele.data('icon')}.svg`; }, 'background-width': '60%', 'background-height': '60%', 'background-position-x': '50%', 'background-position-y': '40%', 'text-margin-y': 10 } }, { selector: 'edge', style: { 'width': 2, 'line-color': '#888', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'target-arrow-color': '#888' } }, { selector: ':selected', style: { 'border-width': 3, 'border-color': '#f8f32b' } } ], layout: { name: 'breadthfirst', directed: true, padding: 30, spacingFactor: 1.2 } }); /* 2. Hilfs-Funktionen für API-Zugriffe */ const get = async endpoint => { try { const response = await fetch(endpoint); if (!response.ok) { console.error(`API-Fehler: ${endpoint} antwortet mit Status ${response.status}`); return []; // Leeres Array zurückgeben bei Fehlern } return await response.json(); } catch (error) { console.error(`Fehler beim Abrufen von ${endpoint}:`, error); return []; // Leeres Array zurückgeben bei Netzwerkfehlern } }; const post = async (endpoint, body) => { try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { console.error(`API-Fehler: ${endpoint} antwortet mit Status ${response.status}`); return {}; // Leeres Objekt zurückgeben bei Fehlern } return await response.json(); } catch (error) { console.error(`Fehler beim POST zu ${endpoint}:`, error); return {}; // Leeres Objekt zurückgeben bei Netzwerkfehlern } }; const del = async endpoint => { try { const response = await fetch(endpoint, { method: 'DELETE' }); if (!response.ok) { console.error(`API-Fehler: ${endpoint} antwortet mit Status ${response.status}`); return {}; // Leeres Objekt zurückgeben bei Fehlern } return await response.json(); } catch (error) { console.error(`Fehler beim DELETE zu ${endpoint}:`, error); return {}; // Leeres Objekt zurückgeben bei Netzwerkfehlern } }; /* 3. Kategorien laden für Style-Informationen */ let categories = await get('/api/categories'); /* 4. Daten laden und Rendering */ const loadMindmap = async () => { try { // Nodes und Beziehungen parallel laden const [nodes, relationships] = await Promise.all([ get('/api/mind_map_nodes'), get('/api/node_relationships') ]); // Graph leeren (für Reload-Fälle) cy.elements().remove(); // Überprüfen, ob nodes ein Array ist, wenn nicht, setze es auf ein leeres Array const nodesArray = Array.isArray(nodes) ? nodes : []; // Knoten zum Graph hinzufügen cy.add( nodesArray.map(node => { // Kategorie-Informationen für Styling abrufen const category = categories.find(c => c.id === node.category_id) || {}; return { data: { id: node.id.toString(), name: node.name, description: node.description, color: node.color_code || category.color_code || '#6b7280', icon: node.icon || category.icon, category_id: node.category_id }, position: node.x && node.y ? { x: node.x, y: node.y } : undefined }; }) ); // Überprüfen, ob relationships ein Array ist, wenn nicht, setze es auf ein leeres Array const relationshipsArray = Array.isArray(relationships) ? relationships : []; // Kanten zum Graph hinzufügen cy.add( relationshipsArray.map(rel => ({ data: { id: `${rel.parent_id}_${rel.child_id}`, source: rel.parent_id.toString(), target: rel.child_id.toString() } })) ); // Wenn keine Knoten geladen wurden, Fallback-Knoten erstellen if (nodesArray.length === 0) { // Mindestens einen Standardknoten hinzufügen cy.add({ data: { id: 'fallback-1', name: 'Mindmap', description: 'Erstellen Sie hier Ihre eigene Mindmap', color: '#3b82f6', icon: 'help-circle' }, position: { x: 300, y: 200 } }); // Erfolgsmeldung anzeigen console.log('Mindmap erfolgreich initialisiert mit Fallback-Knoten'); // Info-Meldung für Benutzer anzeigen const infoBox = document.createElement('div'); infoBox.classList.add('info-message'); infoBox.style.position = 'absolute'; infoBox.style.top = '50%'; infoBox.style.left = '50%'; infoBox.style.transform = 'translate(-50%, -50%)'; infoBox.style.padding = '15px 20px'; infoBox.style.backgroundColor = 'rgba(59, 130, 246, 0.9)'; infoBox.style.color = 'white'; infoBox.style.borderRadius = '8px'; infoBox.style.zIndex = '5'; infoBox.style.maxWidth = '80%'; infoBox.style.textAlign = 'center'; infoBox.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; infoBox.innerHTML = 'Mindmap erfolgreich initialisiert.
Verwenden Sie die Werkzeugleiste, um Knoten hinzuzufügen.'; document.getElementById('cy').appendChild(infoBox); // Meldung nach 5 Sekunden ausblenden setTimeout(() => { infoBox.style.opacity = '0'; infoBox.style.transition = 'opacity 0.5s ease'; setTimeout(() => { if (infoBox.parentNode) { infoBox.parentNode.removeChild(infoBox); } }, 500); }, 5000); } // Layout anwenden wenn keine Positionsdaten vorhanden const nodesWithoutPosition = cy.nodes().filter(node => !node.position() || (node.position().x === 0 && node.position().y === 0) ); if (nodesWithoutPosition.length > 0) { cy.layout({ name: 'breadthfirst', directed: true, padding: 30, spacingFactor: 1.2 }).run(); } // Tooltip-Funktionalität cy.nodes().unbind('mouseover').bind('mouseover', (event) => { const node = event.target; const description = node.data('description'); if (description) { const tooltip = document.getElementById('node-tooltip') || document.createElement('div'); if (!tooltip.id) { tooltip.id = 'node-tooltip'; tooltip.style.position = 'absolute'; tooltip.style.backgroundColor = '#333'; tooltip.style.color = '#fff'; tooltip.style.padding = '8px'; tooltip.style.borderRadius = '4px'; tooltip.style.maxWidth = '250px'; tooltip.style.zIndex = 10; tooltip.style.pointerEvents = 'none'; tooltip.style.transition = 'opacity 0.2s'; tooltip.style.boxShadow = '0 2px 10px rgba(0,0,0,0.3)'; document.body.appendChild(tooltip); } const renderedPosition = node.renderedPosition(); const containerRect = cy.container().getBoundingClientRect(); tooltip.innerHTML = description; tooltip.style.left = (containerRect.left + renderedPosition.x + 25) + 'px'; tooltip.style.top = (containerRect.top + renderedPosition.y - 15) + 'px'; tooltip.style.opacity = '1'; } }); cy.nodes().unbind('mouseout').bind('mouseout', () => { const tooltip = document.getElementById('node-tooltip'); if (tooltip) { tooltip.style.opacity = '0'; } }); } catch (error) { console.error('Fehler beim Laden der Mindmap:', error); alert('Die Mindmap konnte nicht geladen werden. Bitte prüfen Sie die Konsole für Details.'); } }; // Initial laden await loadMindmap(); /* 5. Socket.IO für Echtzeit-Synchronisation */ const socket = io(); socket.on('node_added', async (node) => { // Kategorie-Informationen für Styling abrufen const category = categories.find(c => c.id === node.category_id) || {}; cy.add({ data: { id: node.id.toString(), name: node.name, description: node.description, color: node.color_code || category.color_code || '#6b7280', icon: node.icon || category.icon, category_id: node.category_id } }); // Layout neu anwenden, wenn nötig if (!node.x || !node.y) { cy.layout({ name: 'breadthfirst', directed: true, padding: 30 }).run(); } }); socket.on('node_updated', (node) => { const cyNode = cy.$id(node.id.toString()); if (cyNode.length > 0) { // Kategorie-Informationen für Styling abrufen const category = categories.find(c => c.id === node.category_id) || {}; cyNode.data({ name: node.name, description: node.description, color: node.color_code || category.color_code || '#6b7280', icon: node.icon || category.icon, category_id: node.category_id }); if (node.x && node.y) { cyNode.position({ x: node.x, y: node.y }); } } }); socket.on('node_deleted', (nodeId) => { const cyNode = cy.$id(nodeId.toString()); if (cyNode.length > 0) { cy.remove(cyNode); } }); socket.on('relationship_added', (rel) => { cy.add({ data: { id: `${rel.parent_id}_${rel.child_id}`, source: rel.parent_id.toString(), target: rel.child_id.toString() } }); }); socket.on('relationship_deleted', (rel) => { const edgeId = `${rel.parent_id}_${rel.child_id}`; const cyEdge = cy.$id(edgeId); if (cyEdge.length > 0) { cy.remove(cyEdge); } }); socket.on('category_updated', async () => { // Kategorien neu laden categories = await get('/api/categories'); // Nodes aktualisieren, die diese Kategorie verwenden cy.nodes().forEach(node => { const categoryId = node.data('category_id'); if (categoryId) { const category = categories.find(c => c.id === categoryId); if (category) { node.data('color', node.data('color_code') || category.color_code); node.data('icon', node.data('icon') || category.icon); } } }); }); /* 6. UI-Interaktionen */ // Knoten hinzufügen const btnAddNode = document.getElementById('addNode'); if (btnAddNode) { btnAddNode.addEventListener('click', async () => { const name = prompt('Knotenname eingeben:'); if (!name) return; const description = prompt('Beschreibung (optional):'); // Kategorie auswählen let categoryId = null; if (categories.length > 0) { const categoryOptions = categories.map((c, i) => `${i}: ${c.name}`).join('\n'); const categoryChoice = prompt( `Kategorie auswählen (Nummer eingeben):\n${categoryOptions}`, '0' ); if (categoryChoice !== null) { const index = parseInt(categoryChoice, 10); if (!isNaN(index) && index >= 0 && index < categories.length) { categoryId = categories[index].id; } } } // Knoten erstellen await post('/api/mind_map_node', { name, description, category_id: categoryId }); // Darstellung wird durch Socket.IO Event übernommen }); } // Verbindung hinzufügen const btnAddEdge = document.getElementById('addEdge'); if (btnAddEdge) { btnAddEdge.addEventListener('click', async () => { const sel = cy.$('node:selected'); if (sel.length !== 2) { alert('Bitte genau zwei Knoten auswählen (Parent → Child)'); return; } const [parent, child] = sel.map(node => node.id()); await post('/api/node_relationship', { parent_id: parent, child_id: child }); // Darstellung wird durch Socket.IO Event übernommen }); } // Knoten bearbeiten const btnEditNode = document.getElementById('editNode'); if (btnEditNode) { btnEditNode.addEventListener('click', async () => { const sel = cy.$('node:selected'); if (sel.length !== 1) { alert('Bitte genau einen Knoten auswählen'); return; } const node = sel[0]; const nodeData = node.data(); const name = prompt('Knotenname:', nodeData.name); if (!name) return; const description = prompt('Beschreibung:', nodeData.description || ''); // Kategorie auswählen let categoryId = nodeData.category_id; if (categories.length > 0) { const categoryOptions = categories.map((c, i) => `${i}: ${c.name}`).join('\n'); const categoryChoice = prompt( `Kategorie auswählen (Nummer eingeben):\n${categoryOptions}`, categories.findIndex(c => c.id === categoryId).toString() ); if (categoryChoice !== null) { const index = parseInt(categoryChoice, 10); if (!isNaN(index) && index >= 0 && index < categories.length) { categoryId = categories[index].id; } } } // Knoten aktualisieren await post(`/api/mind_map_node/${nodeData.id}`, { name, description, category_id: categoryId }); // Darstellung wird durch Socket.IO Event übernommen }); } // Knoten löschen const btnDeleteNode = document.getElementById('deleteNode'); if (btnDeleteNode) { btnDeleteNode.addEventListener('click', async () => { const sel = cy.$('node:selected'); if (sel.length !== 1) { alert('Bitte genau einen Knoten auswählen'); return; } if (confirm('Sind Sie sicher, dass Sie diesen Knoten löschen möchten?')) { const nodeId = sel[0].id(); await del(`/api/mind_map_node/${nodeId}`); // Darstellung wird durch Socket.IO Event übernommen } }); } // Verbindung löschen const btnDeleteEdge = document.getElementById('deleteEdge'); if (btnDeleteEdge) { btnDeleteEdge.addEventListener('click', async () => { const sel = cy.$('edge:selected'); if (sel.length !== 1) { alert('Bitte genau eine Verbindung auswählen'); return; } if (confirm('Sind Sie sicher, dass Sie diese Verbindung löschen möchten?')) { const edge = sel[0]; const parentId = edge.source().id(); const childId = edge.target().id(); await del(`/api/node_relationship/${parentId}/${childId}`); // Darstellung wird durch Socket.IO Event übernommen } }); } // Layout aktualisieren const btnReLayout = document.getElementById('reLayout'); if (btnReLayout) { btnReLayout.addEventListener('click', () => { cy.layout({ name: 'breadthfirst', directed: true, padding: 30, spacingFactor: 1.2 }).run(); }); } /* 7. Position speichern bei Drag & Drop */ cy.on('dragfree', 'node', async (e) => { const node = e.target; const position = node.position(); await post(`/api/mind_map_node/${node.id()}/position`, { x: Math.round(position.x), y: Math.round(position.y) }); // Andere Benutzer erhalten die Position über den node_updated Event }); /* 8. Kontextmenü (optional) */ const setupContextMenu = () => { cy.on('cxttap', 'node', function(e) { const node = e.target; const nodeData = node.data(); // Position des Kontextmenüs berechnen const renderedPosition = node.renderedPosition(); const containerRect = cy.container().getBoundingClientRect(); const menuX = containerRect.left + renderedPosition.x; const menuY = containerRect.top + renderedPosition.y; // Kontextmenü erstellen oder aktualisieren let contextMenu = document.getElementById('context-menu'); if (!contextMenu) { contextMenu = document.createElement('div'); contextMenu.id = 'context-menu'; contextMenu.style.position = 'absolute'; contextMenu.style.backgroundColor = '#fff'; contextMenu.style.border = '1px solid #ccc'; contextMenu.style.borderRadius = '4px'; contextMenu.style.padding = '5px 0'; contextMenu.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)'; contextMenu.style.zIndex = 1000; document.body.appendChild(contextMenu); } // Menüinhalte contextMenu.innerHTML = ` `; // Styling für Menüpunkte const menuItems = contextMenu.querySelectorAll('.menu-item'); menuItems.forEach(item => { item.style.padding = '8px 20px'; item.style.cursor = 'pointer'; item.style.fontSize = '14px'; item.addEventListener('mouseover', function() { this.style.backgroundColor = '#f0f0f0'; }); item.addEventListener('mouseout', function() { this.style.backgroundColor = 'transparent'; }); // Event-Handler item.addEventListener('click', async function() { const action = this.getAttribute('data-action'); switch(action) { case 'edit': // Knoten bearbeiten (gleiche Logik wie beim Edit-Button) const name = prompt('Knotenname:', nodeData.name); if (name) { const description = prompt('Beschreibung:', nodeData.description || ''); await post(`/api/mind_map_node/${nodeData.id}`, { name, description }); } break; case 'connect': // Modus zum Verbinden aktivieren cy.nodes().unselect(); node.select(); alert('Wählen Sie nun einen zweiten Knoten aus, um eine Verbindung zu erstellen'); break; case 'delete': if (confirm('Sind Sie sicher, dass Sie diesen Knoten löschen möchten?')) { await del(`/api/mind_map_node/${nodeData.id}`); } break; } // Menü schließen contextMenu.style.display = 'none'; }); }); // Menü positionieren und anzeigen contextMenu.style.left = menuX + 'px'; contextMenu.style.top = menuY + 'px'; contextMenu.style.display = 'block'; // Event-Listener zum Schließen des Menüs const closeMenu = function() { if (contextMenu) { contextMenu.style.display = 'none'; } document.removeEventListener('click', closeMenu); }; // Verzögerung, um den aktuellen Click nicht zu erfassen setTimeout(() => { document.addEventListener('click', closeMenu); }, 0); e.preventDefault(); }); }; // Kontextmenü aktivieren (optional) // setupContextMenu(); /* 9. Export-Funktion (optional) */ const btnExport = document.getElementById('exportMindmap'); if (btnExport) { btnExport.addEventListener('click', () => { const elements = cy.json().elements; const exportData = { nodes: elements.nodes.map(n => ({ id: n.data.id, name: n.data.name, description: n.data.description, category_id: n.data.category_id, x: Math.round(n.position?.x || 0), y: Math.round(n.position?.y || 0) })), relationships: elements.edges.map(e => ({ parent_id: e.data.source, child_id: e.data.target })) }; const blob = new Blob([JSON.stringify(exportData, null, 2)], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'mindmap_export.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } /* 10. Filter-Funktion nach Kategorien (optional) */ const setupCategoryFilters = () => { const filterContainer = document.getElementById('category-filters'); if (!filterContainer || !categories.length) return; filterContainer.innerHTML = ''; // "Alle anzeigen" Option const allBtn = document.createElement('button'); allBtn.innerText = 'Alle Kategorien'; allBtn.className = 'category-filter active'; allBtn.onclick = () => { document.querySelectorAll('.category-filter').forEach(btn => btn.classList.remove('active')); allBtn.classList.add('active'); cy.nodes().removeClass('filtered').show(); cy.edges().show(); }; filterContainer.appendChild(allBtn); // Filter-Button pro Kategorie categories.forEach(category => { const btn = document.createElement('button'); btn.innerText = category.name; btn.className = 'category-filter'; btn.style.backgroundColor = category.color_code; btn.style.color = '#fff'; btn.onclick = () => { document.querySelectorAll('.category-filter').forEach(btn => btn.classList.remove('active')); btn.classList.add('active'); const matchingNodes = cy.nodes().filter(node => node.data('category_id') === category.id); cy.nodes().addClass('filtered').hide(); matchingNodes.removeClass('filtered').show(); // Verbindungen zu/von diesen Knoten anzeigen cy.edges().hide(); matchingNodes.connectedEdges().show(); }; filterContainer.appendChild(btn); }); }; // Filter-Funktionalität aktivieren (optional) // setupCategoryFilters(); /* 11. Suchfunktion (optional) */ const searchInput = document.getElementById('search-mindmap'); if (searchInput) { searchInput.addEventListener('input', (e) => { const searchTerm = e.target.value.toLowerCase(); if (!searchTerm) { cy.nodes().removeClass('search-hidden').show(); cy.edges().show(); return; } cy.nodes().forEach(node => { const name = node.data('name').toLowerCase(); const description = (node.data('description') || '').toLowerCase(); if (name.includes(searchTerm) || description.includes(searchTerm)) { node.removeClass('search-hidden').show(); node.connectedEdges().show(); } else { node.addClass('search-hidden').hide(); // Kanten nur verstecken, wenn beide verbundenen Knoten versteckt sind node.connectedEdges().forEach(edge => { const otherNode = edge.source().id() === node.id() ? edge.target() : edge.source(); if (otherNode.hasClass('search-hidden')) { edge.hide(); } }); } }); }); } console.log('Mindmap erfolgreich initialisiert'); })();