chore: automatic commit 2025-04-30 12:48
This commit is contained in:
572
static/js/modules/chatgpt-assistant.js
Normal file
572
static/js/modules/chatgpt-assistant.js
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* ChatGPT Assistent Modul
|
||||
* Verwaltet die Interaktion mit der OpenAI API und die Benutzeroberfläche des Assistenten
|
||||
*/
|
||||
|
||||
class ChatGPTAssistant {
|
||||
constructor() {
|
||||
this.messages = [];
|
||||
this.isOpen = false;
|
||||
this.isLoading = false;
|
||||
this.container = null;
|
||||
this.chatHistory = null;
|
||||
this.inputField = null;
|
||||
this.suggestionArea = null;
|
||||
this.maxRetries = 2;
|
||||
this.retryCount = 0;
|
||||
this.markdownParser = null;
|
||||
this.initializeMarkdownParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Markdown-Parser
|
||||
*/
|
||||
async initializeMarkdownParser() {
|
||||
// Dynamisch marked.js laden, wenn noch nicht vorhanden
|
||||
if (!window.marked) {
|
||||
try {
|
||||
// Prüfen, ob marked.js bereits im Dokument geladen ist
|
||||
if (!document.querySelector('script[src*="marked"]')) {
|
||||
// Falls nicht, Script-Tag erstellen und einfügen
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
|
||||
script.async = true;
|
||||
|
||||
// Promise erstellen, das resolved wird, wenn das Script geladen wurde
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
console.log('Marked.js erfolgreich geladen');
|
||||
}
|
||||
|
||||
// Marked konfigurieren
|
||||
this.markdownParser = window.marked;
|
||||
this.markdownParser.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
sanitize: true,
|
||||
smartLists: true,
|
||||
smartypants: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden von marked.js:', error);
|
||||
// Fallback-Parser, der nur einfache Absätze erkennt
|
||||
this.markdownParser = {
|
||||
parse: (text) => {
|
||||
return text.split('\n').map(line => {
|
||||
if (line.trim() === '') return '<br>';
|
||||
return `<p>${line}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Marked ist bereits geladen
|
||||
this.markdownParser = window.marked;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Assistenten und fügt die UI zum DOM hinzu
|
||||
*/
|
||||
init() {
|
||||
// Assistent-Container erstellen
|
||||
this.createAssistantUI();
|
||||
|
||||
// Event-Listener hinzufügen
|
||||
this.setupEventListeners();
|
||||
|
||||
// Ersten Willkommensnachricht anzeigen
|
||||
this.addMessage("assistant", "Hallo! Ich bin dein KI-Assistent (4o-mini) und habe Zugriff auf die Wissensdatenbank. Wie kann ich dir helfen?\n\nDu kannst mir Fragen über:\n- **Gedanken** in der Datenbank\n- **Kategorien** und Wissenschaftsbereiche\n- **Mindmaps** und Wissensverknüpfungen\n\nstellen.");
|
||||
|
||||
// Vorschläge anzeigen
|
||||
this.showSuggestions([
|
||||
"Zeige mir Gedanken zur künstlichen Intelligenz",
|
||||
"Welche Kategorien gibt es in der Datenbank?",
|
||||
"Suche nach Mindmaps zum Thema Informatik"
|
||||
]);
|
||||
|
||||
console.log('KI-Assistent initialisiert!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt die UI-Elemente für den Assistenten
|
||||
*/
|
||||
createAssistantUI() {
|
||||
// Hauptcontainer erstellen
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'chatgpt-assistant';
|
||||
this.container.className = 'fixed bottom-4 right-4 z-50 flex flex-col';
|
||||
|
||||
// Button zum Öffnen/Schließen des Assistenten
|
||||
const toggleButton = document.createElement('button');
|
||||
toggleButton.id = 'assistant-toggle';
|
||||
toggleButton.className = 'ml-auto bg-primary-600 hover:bg-primary-700 text-white rounded-full p-3 shadow-lg transition-all duration-300 mb-2';
|
||||
toggleButton.innerHTML = '<i class="fas fa-robot text-xl"></i>';
|
||||
|
||||
// Chat-Container
|
||||
const chatContainer = document.createElement('div');
|
||||
chatContainer.id = 'assistant-chat';
|
||||
chatContainer.className = 'bg-white dark:bg-dark-800 rounded-lg shadow-xl overflow-hidden transition-all duration-300 w-80 md:w-96 max-h-0 opacity-0';
|
||||
|
||||
// Chat-Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'bg-primary-600 text-white p-3 flex items-center justify-between';
|
||||
header.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
<span>KI-Assistent (4o-mini)</span>
|
||||
</div>
|
||||
<button id="assistant-close" class="text-white hover:text-gray-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Chat-Verlauf
|
||||
this.chatHistory = document.createElement('div');
|
||||
this.chatHistory.id = 'assistant-history';
|
||||
this.chatHistory.className = 'p-3 overflow-y-auto max-h-96 space-y-3';
|
||||
|
||||
// Vorschlagsbereich
|
||||
this.suggestionArea = document.createElement('div');
|
||||
this.suggestionArea.id = 'assistant-suggestions';
|
||||
this.suggestionArea.className = 'px-3 pb-2 flex flex-wrap gap-2 overflow-x-auto hidden';
|
||||
|
||||
// Chat-Eingabe
|
||||
const inputContainer = document.createElement('div');
|
||||
inputContainer.className = 'border-t border-gray-200 dark:border-dark-600 p-3 flex items-center';
|
||||
|
||||
this.inputField = document.createElement('input');
|
||||
this.inputField.type = 'text';
|
||||
this.inputField.placeholder = 'Stelle eine Frage zur Wissensdatenbank...';
|
||||
this.inputField.className = 'flex-1 border border-gray-300 dark:border-dark-600 dark:bg-dark-700 dark:text-white rounded-l-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500';
|
||||
|
||||
const sendButton = document.createElement('button');
|
||||
sendButton.id = 'assistant-send';
|
||||
sendButton.className = 'bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-r-lg';
|
||||
sendButton.innerHTML = '<i class="fas fa-paper-plane"></i>';
|
||||
|
||||
// Elemente zusammenfügen
|
||||
inputContainer.appendChild(this.inputField);
|
||||
inputContainer.appendChild(sendButton);
|
||||
|
||||
chatContainer.appendChild(header);
|
||||
chatContainer.appendChild(this.chatHistory);
|
||||
chatContainer.appendChild(this.suggestionArea);
|
||||
chatContainer.appendChild(inputContainer);
|
||||
|
||||
this.container.appendChild(toggleButton);
|
||||
this.container.appendChild(chatContainer);
|
||||
|
||||
// Zum DOM hinzufügen
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtet Event-Listener für die Benutzeroberfläche ein
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Toggle-Button
|
||||
const toggleButton = document.getElementById('assistant-toggle');
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener('click', () => this.toggleAssistant());
|
||||
}
|
||||
|
||||
// Schließen-Button
|
||||
const closeButton = document.getElementById('assistant-close');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => this.toggleAssistant(false));
|
||||
}
|
||||
|
||||
// Senden-Button
|
||||
const sendButton = document.getElementById('assistant-send');
|
||||
if (sendButton) {
|
||||
sendButton.addEventListener('click', () => this.sendMessage());
|
||||
}
|
||||
|
||||
// Enter-Taste im Eingabefeld
|
||||
if (this.inputField) {
|
||||
this.inputField.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Vorschläge klickbar machen
|
||||
if (this.suggestionArea) {
|
||||
this.suggestionArea.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('suggestion-pill')) {
|
||||
this.inputField.value = e.target.textContent;
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet oder schließt den Assistenten
|
||||
* @param {boolean} state - Optional: erzwingt einen bestimmten Zustand
|
||||
*/
|
||||
toggleAssistant(state = null) {
|
||||
const chatContainer = document.getElementById('assistant-chat');
|
||||
if (!chatContainer) return;
|
||||
|
||||
this.isOpen = state !== null ? state : !this.isOpen;
|
||||
|
||||
if (this.isOpen) {
|
||||
chatContainer.classList.remove('max-h-0', 'opacity-0');
|
||||
chatContainer.classList.add('max-h-[32rem]', 'opacity-100');
|
||||
if (this.inputField) this.inputField.focus();
|
||||
|
||||
// Zeige Vorschläge wenn verfügbar
|
||||
if (this.suggestionArea && this.suggestionArea.children.length > 0) {
|
||||
this.suggestionArea.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
chatContainer.classList.remove('max-h-[32rem]', 'opacity-100');
|
||||
chatContainer.classList.add('max-h-0', 'opacity-0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine Nachricht zum Chat-Verlauf hinzu
|
||||
* @param {string} sender - 'user' oder 'assistant'
|
||||
* @param {string} text - Nachrichtentext
|
||||
*/
|
||||
addMessage(sender, text) {
|
||||
// Nachricht zum Verlauf hinzufügen
|
||||
this.messages.push({ role: sender, content: text });
|
||||
|
||||
// DOM-Element erstellen
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.className = `flex ${sender === 'user' ? 'justify-end' : 'justify-start'}`;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = sender === 'user'
|
||||
? 'bg-primary-100 dark:bg-primary-900 text-gray-800 dark:text-white rounded-lg py-2 px-3 max-w-[85%]'
|
||||
: 'bg-gray-100 dark:bg-dark-700 text-gray-800 dark:text-white rounded-lg py-2 px-3 max-w-[85%]';
|
||||
|
||||
// Formatierung des Texts (mit Markdown für Assistent-Nachrichten)
|
||||
let formattedText = '';
|
||||
|
||||
if (sender === 'assistant' && this.markdownParser) {
|
||||
// Für Assistentnachrichten Markdown verwenden
|
||||
try {
|
||||
formattedText = this.markdownParser.parse(text);
|
||||
|
||||
// CSS für Markdown-Formatierung hinzufügen
|
||||
const markdownStyles = `
|
||||
.markdown-bubble h1, .markdown-bubble h2, .markdown-bubble h3,
|
||||
.markdown-bubble h4, .markdown-bubble h5, .markdown-bubble h6 {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.markdown-bubble h1 { font-size: 1.4rem; }
|
||||
.markdown-bubble h2 { font-size: 1.3rem; }
|
||||
.markdown-bubble h3 { font-size: 1.2rem; }
|
||||
.markdown-bubble h4 { font-size: 1.1rem; }
|
||||
.markdown-bubble ul, .markdown-bubble ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-bubble ul { list-style-type: disc; }
|
||||
.markdown-bubble ol { list-style-type: decimal; }
|
||||
.markdown-bubble p { margin: 0.5rem 0; }
|
||||
.markdown-bubble code {
|
||||
font-family: monospace;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.markdown-bubble pre {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-bubble pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown-bubble blockquote {
|
||||
border-left: 3px solid rgba(0, 0, 0, 0.2);
|
||||
padding-left: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.dark .markdown-bubble code {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.dark .markdown-bubble pre {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.dark .markdown-bubble blockquote {
|
||||
border-left-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
// Füge die Styles hinzu, wenn sie noch nicht vorhanden sind
|
||||
if (!document.querySelector('#markdown-chat-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'markdown-chat-styles';
|
||||
style.textContent = markdownStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Klasse für Markdown-Formatierung hinzufügen
|
||||
bubble.classList.add('markdown-bubble');
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Markdown-Formatierung:', error);
|
||||
// Fallback zur einfachen Formatierung
|
||||
formattedText = text.split('\n').map(line => {
|
||||
if (line.trim() === '') return '<br>';
|
||||
return `<p>${line}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
// Für Benutzernachrichten einfache Formatierung
|
||||
formattedText = text.split('\n').map(line => {
|
||||
if (line.trim() === '') return '<br>';
|
||||
return `<p>${line}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
bubble.innerHTML = formattedText;
|
||||
|
||||
messageEl.appendChild(bubble);
|
||||
|
||||
if (this.chatHistory) {
|
||||
this.chatHistory.appendChild(messageEl);
|
||||
|
||||
// Scroll zum Ende des Verlaufs
|
||||
this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Vorschläge als klickbare Pills an
|
||||
* @param {string[]} suggestions - Liste von Vorschlägen
|
||||
*/
|
||||
showSuggestions(suggestions) {
|
||||
if (!this.suggestionArea) return;
|
||||
|
||||
// Vorherige Vorschläge entfernen
|
||||
this.suggestionArea.innerHTML = '';
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
suggestions.forEach(suggestion => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'suggestion-pill text-sm bg-gray-200 dark:bg-dark-600 hover:bg-gray-300 dark:hover:bg-dark-500 text-gray-800 dark:text-gray-200 rounded-full px-3 py-1 mb-2 transition-colors';
|
||||
pill.textContent = suggestion;
|
||||
this.suggestionArea.appendChild(pill);
|
||||
});
|
||||
|
||||
this.suggestionArea.classList.remove('hidden');
|
||||
} else {
|
||||
this.suggestionArea.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet die Benutzernachricht an den Server und zeigt die Antwort an
|
||||
*/
|
||||
async sendMessage() {
|
||||
if (!this.inputField) return;
|
||||
|
||||
const userInput = this.inputField.value.trim();
|
||||
if (!userInput || this.isLoading) return;
|
||||
|
||||
// Vorschläge ausblenden
|
||||
if (this.suggestionArea) {
|
||||
this.suggestionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Benutzernachricht anzeigen
|
||||
this.addMessage('user', userInput);
|
||||
|
||||
// Eingabefeld zurücksetzen
|
||||
this.inputField.value = '';
|
||||
|
||||
// Ladeindikator anzeigen
|
||||
this.isLoading = true;
|
||||
this.showLoadingIndicator();
|
||||
|
||||
try {
|
||||
console.log('Sende Anfrage an KI-Assistent API...');
|
||||
// Anfrage an den Server senden
|
||||
const response = await fetch('/api/assistant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: this.messages
|
||||
}),
|
||||
cache: 'no-cache', // Kein Cache verwenden
|
||||
credentials: 'same-origin' // Cookies senden
|
||||
});
|
||||
|
||||
// Ladeindikator entfernen
|
||||
this.removeLoadingIndicator();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Serverfehler: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Antwort erhalten:', data);
|
||||
|
||||
// Antwort anzeigen
|
||||
if (data.response) {
|
||||
this.addMessage('assistant', data.response);
|
||||
|
||||
// Neue Vorschläge basierend auf dem aktuellen Kontext anzeigen
|
||||
this.generateContextualSuggestions();
|
||||
|
||||
// Erfolgreiche Anfrage zurücksetzen
|
||||
this.retryCount = 0;
|
||||
} else if (data.error) {
|
||||
this.addMessage('assistant', `Fehler: ${data.error}`);
|
||||
} else {
|
||||
throw new Error('Unerwartetes Antwortformat vom Server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Kommunikation mit dem Assistenten:', error);
|
||||
|
||||
// Ladeindikator entfernen, falls noch vorhanden
|
||||
this.removeLoadingIndicator();
|
||||
|
||||
// Fehlermeldung anzeigen oder Wiederholungsversuch starten
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
this.addMessage('assistant', 'Es gab ein Problem mit der Anfrage. Ich versuche es erneut...');
|
||||
|
||||
// Kurze Verzögerung vor dem erneuten Versuch
|
||||
setTimeout(() => {
|
||||
// Letzte Benutzernachricht aus dem Messages-Array entfernen
|
||||
const lastUserMessage = this.messages[this.messages.length - 2].content;
|
||||
this.messages = this.messages.slice(0, -2); // Entferne Benutzernachricht und Fehlermeldung
|
||||
|
||||
// Erneuter Versand mit gleicher Nachricht
|
||||
this.inputField.value = lastUserMessage;
|
||||
this.sendMessage();
|
||||
}, 1500);
|
||||
} else {
|
||||
// Maximale Anzahl an Wiederholungsversuchen erreicht
|
||||
this.addMessage('assistant', 'Es tut mir leid, aber es gab ein Problem bei der Verarbeitung deiner Anfrage. Bitte versuche es später noch einmal.');
|
||||
this.retryCount = 0; // Zurücksetzen für die nächste Anfrage
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert kontextbasierte Vorschläge basierend auf dem aktuellen Chat-Verlauf
|
||||
*/
|
||||
generateContextualSuggestions() {
|
||||
// Basierend auf letzter Antwort des Assistenten, verschiedene Vorschläge generieren
|
||||
const lastAssistantMessage = this.messages.findLast(msg => msg.role === 'assistant')?.content || '';
|
||||
|
||||
let suggestions = [];
|
||||
|
||||
// Intelligente Vorschläge basierend auf Kontext
|
||||
if (lastAssistantMessage.includes('Künstliche Intelligenz') ||
|
||||
lastAssistantMessage.includes('KI ') ||
|
||||
lastAssistantMessage.includes('AI ')) {
|
||||
suggestions = [
|
||||
"Wie wird KI in der Wissenschaft eingesetzt?",
|
||||
"Zeige mir Gedanken zum maschinellen Lernen",
|
||||
"Was ist der Unterschied zwischen KI und ML?"
|
||||
];
|
||||
} else if (lastAssistantMessage.includes('Kategorie') ||
|
||||
lastAssistantMessage.includes('Kategorien')) {
|
||||
suggestions = [
|
||||
"Zeige mir die Unterkategorien",
|
||||
"Welche Gedanken gehören zu dieser Kategorie?",
|
||||
"Liste alle Wissenschaftskategorien auf"
|
||||
];
|
||||
} else if (lastAssistantMessage.includes('Mindmap') ||
|
||||
lastAssistantMessage.includes('Visualisierung')) {
|
||||
suggestions = [
|
||||
"Wie kann ich eine eigene Mindmap erstellen?",
|
||||
"Zeige mir Beispiele für Mindmaps",
|
||||
"Wie funktionieren die Verbindungen in Mindmaps?"
|
||||
];
|
||||
} else {
|
||||
// Standardvorschläge
|
||||
suggestions = [
|
||||
"Erzähle mir mehr dazu",
|
||||
"Gibt es Beispiele dafür?",
|
||||
"Wie kann ich diese Information nutzen?"
|
||||
];
|
||||
}
|
||||
|
||||
this.showSuggestions(suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen Ladeindikator im Chat an
|
||||
*/
|
||||
showLoadingIndicator() {
|
||||
if (!this.chatHistory) return;
|
||||
|
||||
// Entferne vorhandenen Ladeindikator (falls vorhanden)
|
||||
this.removeLoadingIndicator();
|
||||
|
||||
const loadingEl = document.createElement('div');
|
||||
loadingEl.id = 'assistant-loading';
|
||||
loadingEl.className = 'flex justify-start';
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bg-gray-100 dark:bg-dark-700 text-gray-800 dark:text-white rounded-lg py-2 px-3';
|
||||
bubble.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||||
|
||||
loadingEl.appendChild(bubble);
|
||||
this.chatHistory.appendChild(loadingEl);
|
||||
|
||||
// Scroll zum Ende des Verlaufs
|
||||
this.chatHistory.scrollTop = this.chatHistory.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt den Ladeindikator aus dem Chat
|
||||
*/
|
||||
removeLoadingIndicator() {
|
||||
const loadingIndicator = document.getElementById('assistant-loading');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den Assistenten und sendet eine vorgegebene Frage
|
||||
* @param {string} question - Die zu stellende Frage
|
||||
*/
|
||||
async sendQuestion(question) {
|
||||
if (!question || this.isLoading) return;
|
||||
|
||||
// Assistenten öffnen
|
||||
this.toggleAssistant(true);
|
||||
|
||||
// Kurze Verzögerung, um sicherzustellen, dass der UI vollständig geöffnet ist
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Frage in Eingabefeld setzen
|
||||
if (this.inputField) {
|
||||
this.inputField.value = question;
|
||||
|
||||
// Sende die Frage
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mache die Klasse global verfügbar
|
||||
window.ChatGPTAssistant = ChatGPTAssistant;
|
||||
777
static/js/modules/mindmap-page.js
Normal file
777
static/js/modules/mindmap-page.js
Normal file
@@ -0,0 +1,777 @@
|
||||
/**
|
||||
* Mindmap-Seite JavaScript
|
||||
* Spezifische Funktionen für die Mindmap-Seite
|
||||
*/
|
||||
|
||||
// Füge das Modul zum globalen MindMap-Objekt hinzu
|
||||
if (!window.MindMap) {
|
||||
window.MindMap = {};
|
||||
}
|
||||
|
||||
// Registriere den Initialisierer im MindMap-Objekt
|
||||
if (window.MindMap) {
|
||||
window.MindMap.pageInitializers = window.MindMap.pageInitializers || {};
|
||||
window.MindMap.pageInitializers.mindmap = initMindmapPage;
|
||||
}
|
||||
|
||||
// Initialisiere die Mindmap-Seite nur, wenn alle Abhängigkeiten vorhanden sind
|
||||
if (window.MindMap && typeof MindMapVisualization !== 'undefined') {
|
||||
if (document.body && document.body.dataset && document.body.dataset.page === 'mindmap') {
|
||||
window.MindMap.pageInitializers = window.MindMap.pageInitializers || {};
|
||||
window.MindMap.pageInitializers.mindmap = initMindmapPage;
|
||||
initMindmapPage();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Prüfe, ob wir auf der Mindmap-Seite sind und initialisiere
|
||||
if (document.body && document.body.dataset && document.body.dataset.page === 'mindmap') {
|
||||
initMindmapPage();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialisiert die Mindmap-Seite
|
||||
*/
|
||||
function initMindmapPage() {
|
||||
console.log('Mindmap-Seite Initialisierung startet...');
|
||||
console.log('D3 Bibliothek verfügbar:', typeof d3 !== 'undefined');
|
||||
console.log('MindMapVisualization verfügbar:', typeof MindMapVisualization !== 'undefined');
|
||||
|
||||
const mindmapContainer = document.getElementById('mindmap-container');
|
||||
const thoughtsContainer = document.getElementById('thoughts-container');
|
||||
|
||||
if (!mindmapContainer) {
|
||||
console.error('Mindmap-Container nicht gefunden!');
|
||||
return;
|
||||
}
|
||||
console.log('Mindmap-Container gefunden:', mindmapContainer);
|
||||
|
||||
// Prüfe, ob D3.js geladen ist
|
||||
if (typeof d3 === 'undefined') {
|
||||
console.error('D3.js ist nicht geladen!');
|
||||
mindmapContainer.innerHTML = `
|
||||
<div class="glass-effect p-6 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-xl">D3.js konnte nicht geladen werden. Bitte laden Sie die Seite neu.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe, ob MindMapVisualization definiert ist
|
||||
if (typeof MindMapVisualization === 'undefined') {
|
||||
console.error('MindMapVisualization-Klasse ist nicht definiert!');
|
||||
mindmapContainer.innerHTML = `
|
||||
<div class="glass-effect p-6 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-xl">MindMap-Visualisierung konnte nicht geladen werden. Bitte laden Sie die Seite neu.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Erstelle die Mindmap-Visualisierung
|
||||
try {
|
||||
console.log('Versuche, MindMapVisualization zu erstellen...');
|
||||
const mindmap = new MindMapVisualization('#mindmap-container', {
|
||||
height: 600,
|
||||
onNodeClick: handleNodeClick
|
||||
});
|
||||
|
||||
// Globale Referenz für die Zoom-Buttons erstellen
|
||||
window.mindmapInstance = mindmap;
|
||||
|
||||
// Lade die Mindmap-Daten
|
||||
mindmap.loadData();
|
||||
console.log('MindMapVisualization erfolgreich erstellt und geladen');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der MindMapVisualization:', error);
|
||||
mindmapContainer.innerHTML = `
|
||||
<div class="glass-effect p-6 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-xl">Fehler beim Erstellen der Mindmap-Visualisierung:</p>
|
||||
<p class="text-md mt-2">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Suchfunktion für die Mindmap
|
||||
const searchInput = document.getElementById('mindmap-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
mindmap.filterBySearchTerm(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt Klicks auf Mindmap-Knoten
|
||||
*/
|
||||
async function handleNodeClick(node) {
|
||||
if (!thoughtsContainer) return;
|
||||
|
||||
// Zeige Lade-Animation
|
||||
thoughtsContainer.innerHTML = `
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-400"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// Lade Gedanken für den ausgewählten Knoten
|
||||
const response = await fetch(`/api/nodes/${node.id}/thoughts`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const thoughts = await response.json();
|
||||
|
||||
// Gedanken anzeigen
|
||||
renderThoughts(thoughts, node.name);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Gedanken:', error);
|
||||
thoughtsContainer.innerHTML = `
|
||||
<div class="glass-effect p-6 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-xl">Fehler beim Laden der Gedanken.</p>
|
||||
<p class="text-gray-300">Bitte versuchen Sie es später erneut.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Gedanken in den Container
|
||||
*/
|
||||
function renderThoughts(thoughts, nodeName) {
|
||||
// Wenn keine Gedanken vorhanden sind
|
||||
if (thoughts.length === 0) {
|
||||
thoughtsContainer.innerHTML = `
|
||||
<div class="glass-effect p-6 text-center">
|
||||
<div class="text-blue-400 mb-4">
|
||||
<i class="fa-solid fa-info-circle text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-xl">Keine Gedanken für "${nodeName}" vorhanden.</p>
|
||||
<button id="add-thought-btn" class="btn-primary mt-4">
|
||||
<i class="fa-solid fa-plus mr-2"></i> Gedanke hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event-Listener für den Button
|
||||
document.getElementById('add-thought-btn').addEventListener('click', () => {
|
||||
openAddThoughtModal(nodeName);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Gedanken anzeigen
|
||||
thoughtsContainer.innerHTML = `
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold text-white">Gedanken zu "${nodeName}"</h2>
|
||||
<button id="add-thought-btn" class="btn-primary">
|
||||
<i class="fa-solid fa-plus mr-2"></i> Neuer Gedanke
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4" id="thoughts-grid"></div>
|
||||
`;
|
||||
|
||||
// Button-Event-Listener
|
||||
document.getElementById('add-thought-btn').addEventListener('click', () => {
|
||||
openAddThoughtModal(nodeName);
|
||||
});
|
||||
|
||||
// Gedanken-Karten rendern
|
||||
const thoughtsGrid = document.getElementById('thoughts-grid');
|
||||
thoughts.forEach((thought, index) => {
|
||||
const card = createThoughtCard(thought);
|
||||
|
||||
// Animation verzögern für gestaffeltes Erscheinen
|
||||
setTimeout(() => {
|
||||
card.classList.add('opacity-100');
|
||||
card.classList.remove('opacity-0', 'translate-y-4');
|
||||
}, index * 100);
|
||||
|
||||
thoughtsGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Gedanken-Karte
|
||||
*/
|
||||
function createThoughtCard(thought) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card transition-all duration-300 opacity-0 translate-y-4 transform hover:shadow-lg border-l-4';
|
||||
card.style.borderLeftColor = thought.color_code || '#4080ff';
|
||||
|
||||
// Karten-Inhalt
|
||||
card.innerHTML = `
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="text-lg font-bold text-white">${thought.title}</h3>
|
||||
<div class="text-sm text-gray-400">${thought.timestamp}</div>
|
||||
</div>
|
||||
<div class="prose dark:prose-invert mt-2">
|
||||
<p>${thought.content}</p>
|
||||
</div>
|
||||
${thought.keywords ? `
|
||||
<div class="flex flex-wrap gap-1 mt-3">
|
||||
${thought.keywords.split(',').map(keyword =>
|
||||
`<span class="px-2 py-1 text-xs rounded-full bg-secondary-700 text-white">${keyword.trim()}</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-400">
|
||||
<i class="fa-solid fa-user mr-1"></i> ${thought.author}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button class="text-sm px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
||||
onclick="showComments(${thought.id})">
|
||||
<i class="fa-solid fa-comments mr-1"></i> Kommentare
|
||||
</button>
|
||||
<button class="text-sm px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
||||
onclick="showRelations(${thought.id})">
|
||||
<i class="fa-solid fa-diagram-project mr-1"></i> Beziehungen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet das Modal zum Hinzufügen eines neuen Gedankens
|
||||
*/
|
||||
function openAddThoughtModal(nodeName) {
|
||||
// Node-Information extrahieren
|
||||
let nodeId, nodeTitle;
|
||||
|
||||
if (typeof nodeName === 'string') {
|
||||
// Wenn nur ein String übergeben wurde
|
||||
nodeTitle = nodeName;
|
||||
// Versuche nodeId aus der Mindmap zu finden
|
||||
const nodeElement = d3.selectAll('.node-group').filter(d => d.name === nodeName);
|
||||
if (nodeElement.size() > 0) {
|
||||
nodeId = nodeElement.datum().id;
|
||||
}
|
||||
} else if (typeof nodeName === 'object') {
|
||||
// Wenn ein Node-Objekt übergeben wurde
|
||||
nodeId = nodeName.id;
|
||||
nodeTitle = nodeName.name;
|
||||
} else {
|
||||
console.error('Ungültiger Node-Parameter', nodeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal-Struktur erstellen
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 z-50 flex items-center justify-center p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" id="modal-backdrop"></div>
|
||||
<div class="glass-effect relative rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto z-10 transform transition-all">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold text-white flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-primary-400 mr-2"></span>
|
||||
Neuer Gedanke zu "${nodeTitle}"
|
||||
</h3>
|
||||
<button id="close-modal-btn" class="text-gray-400 hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-xmark text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="add-thought-form" class="space-y-4">
|
||||
<input type="hidden" id="node_id" name="node_id" value="${nodeId || ''}">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-300">Titel</label>
|
||||
<input type="text" id="title" name="title" required
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border border-dark-500 text-white p-2.5 focus:ring-2 focus:ring-primary-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="content" class="block text-sm font-medium text-gray-300">Inhalt</label>
|
||||
<textarea id="content" name="content" rows="5" required
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border border-dark-500 text-white p-2.5 focus:ring-2 focus:ring-primary-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="keywords" class="block text-sm font-medium text-gray-300">Schlüsselwörter (kommagetrennt)</label>
|
||||
<input type="text" id="keywords" name="keywords"
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border border-dark-500 text-white p-2.5 focus:ring-2 focus:ring-primary-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="abstract" class="block text-sm font-medium text-gray-300">Zusammenfassung (optional)</label>
|
||||
<textarea id="abstract" name="abstract" rows="2"
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border border-dark-500 text-white p-2.5 focus:ring-2 focus:ring-primary-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="color_code" class="block text-sm font-medium text-gray-300">Farbcode</label>
|
||||
<div class="flex space-x-2 mt-1">
|
||||
<input type="color" id="color_code" name="color_code" value="#4080ff"
|
||||
class="h-10 w-10 rounded bg-dark-700 border border-dark-500">
|
||||
<select id="predefined_colors"
|
||||
class="block flex-grow rounded-md bg-dark-700 border border-dark-500 text-white p-2.5">
|
||||
<option value="#4080ff">Blau</option>
|
||||
<option value="#a040ff">Lila</option>
|
||||
<option value="#40bf80">Grün</option>
|
||||
<option value="#ff4080">Rot</option>
|
||||
<option value="#ffaa00">Orange</option>
|
||||
<option value="#00ccff">Türkis</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between pt-4">
|
||||
<div class="flex items-center">
|
||||
<div class="relative">
|
||||
<button type="button" id="open-relation-btn" class="btn-outline text-sm pl-3 pr-9">
|
||||
<i class="fa-solid fa-diagram-project mr-2"></i> Verbindung
|
||||
<i class="fa-solid fa-chevron-down absolute right-3 top-1/2 transform -translate-y-1/2"></i>
|
||||
</button>
|
||||
<div id="relation-menu" class="absolute left-0 mt-2 w-60 rounded-md shadow-lg bg-dark-800 ring-1 ring-black ring-opacity-5 z-10 hidden">
|
||||
<div class="py-1">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-gray-400 border-b border-dark-600">BEZIEHUNGSTYPEN</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="supports">
|
||||
<i class="fa-solid fa-circle-arrow-up text-green-400 mr-2"></i> Stützt
|
||||
</button>
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="contradicts">
|
||||
<i class="fa-solid fa-circle-arrow-down text-red-400 mr-2"></i> Widerspricht
|
||||
</button>
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="builds_upon">
|
||||
<i class="fa-solid fa-arrow-right text-blue-400 mr-2"></i> Baut auf auf
|
||||
</button>
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="generalizes">
|
||||
<i class="fa-solid fa-arrow-up-wide-short text-purple-400 mr-2"></i> Verallgemeinert
|
||||
</button>
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="specifies">
|
||||
<i class="fa-solid fa-arrow-down-wide-short text-yellow-400 mr-2"></i> Spezifiziert
|
||||
</button>
|
||||
<button type="button" class="relation-type-btn w-full text-left px-4 py-2 text-sm text-white hover:bg-dark-600" data-type="inspires">
|
||||
<i class="fa-solid fa-lightbulb text-amber-400 mr-2"></i> Inspiriert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="relation_type" name="relation_type" value="">
|
||||
<input type="hidden" id="relation_target" name="relation_target" value="">
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button type="button" id="cancel-btn" class="btn-outline">Abbrechen</button>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fa-solid fa-save mr-2"></i> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Focus auf das erste Feld setzen
|
||||
setTimeout(() => {
|
||||
modal.querySelector('#title').focus();
|
||||
}, 100);
|
||||
|
||||
// Event-Listener hinzufügen
|
||||
modal.querySelector('#modal-backdrop').addEventListener('click', closeModal);
|
||||
modal.querySelector('#close-modal-btn').addEventListener('click', closeModal);
|
||||
modal.querySelector('#cancel-btn').addEventListener('click', closeModal);
|
||||
|
||||
// Farbauswahl-Event-Listener
|
||||
const colorInput = modal.querySelector('#color_code');
|
||||
const predefinedColors = modal.querySelector('#predefined_colors');
|
||||
|
||||
predefinedColors.addEventListener('change', function() {
|
||||
colorInput.value = this.value;
|
||||
});
|
||||
|
||||
// Beziehungsmenü-Funktionalität
|
||||
const relationBtn = modal.querySelector('#open-relation-btn');
|
||||
const relationMenu = modal.querySelector('#relation-menu');
|
||||
|
||||
relationBtn.addEventListener('click', function() {
|
||||
relationMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Klick außerhalb des Menüs schließt es
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!relationBtn.contains(event.target) && !relationMenu.contains(event.target)) {
|
||||
relationMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Beziehungstyp-Auswahl
|
||||
const relationTypeBtns = modal.querySelectorAll('.relation-type-btn');
|
||||
const relationTypeInput = modal.querySelector('#relation_type');
|
||||
|
||||
relationTypeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const relationType = this.dataset.type;
|
||||
relationTypeInput.value = relationType;
|
||||
|
||||
// Sichtbare Anzeige aktualisieren
|
||||
relationBtn.innerHTML = `
|
||||
<i class="fa-solid fa-diagram-project mr-2"></i>
|
||||
${this.innerText.trim()}
|
||||
<i class="fa-solid fa-chevron-down absolute right-3 top-1/2 transform -translate-y-1/2"></i>
|
||||
`;
|
||||
|
||||
// Menü schließen
|
||||
relationMenu.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Form-Submit-Handler
|
||||
const form = modal.querySelector('#add-thought-form');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const thoughtData = {
|
||||
node_id: formData.get('node_id'),
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
keywords: formData.get('keywords'),
|
||||
abstract: formData.get('abstract'),
|
||||
color_code: formData.get('color_code'),
|
||||
relation_type: formData.get('relation_type'),
|
||||
relation_target: formData.get('relation_target')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/thoughts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(thoughtData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Speichern des Gedankens.');
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
closeModal();
|
||||
|
||||
// Gedanken neu laden
|
||||
if (nodeId) {
|
||||
handleNodeClick({ id: nodeId, name: nodeTitle });
|
||||
}
|
||||
|
||||
// Erfolgsbenachrichtigung
|
||||
if (window.MindMap && window.MindMap.showNotification) {
|
||||
window.MindMap.showNotification('Gedanke erfolgreich gespeichert.', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error);
|
||||
if (window.MindMap && window.MindMap.showNotification) {
|
||||
window.MindMap.showNotification('Fehler beim Speichern des Gedankens.', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Modal schließen
|
||||
function closeModal() {
|
||||
modal.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Füge globale Funktionen für das Mindmap-Objekt hinzu
|
||||
*/
|
||||
window.showComments = async function(thoughtId) {
|
||||
try {
|
||||
// Lade-Animation erstellen
|
||||
const modal = createModalWithLoading('Kommentare werden geladen...');
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Kommentare laden
|
||||
const response = await fetch(`/api/comments/${thoughtId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const comments = await response.json();
|
||||
|
||||
// Modal mit Kommentaren aktualisieren
|
||||
updateModalWithComments(modal, comments, thoughtId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Kommentare:', error);
|
||||
if (window.MindMap && window.MindMap.showNotification) {
|
||||
window.MindMap.showNotification('Fehler beim Laden der Kommentare.', 'error');
|
||||
} else {
|
||||
alert('Fehler beim Laden der Kommentare.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeigt die Beziehungen eines Gedankens an
|
||||
*/
|
||||
window.showRelations = async function(thoughtId) {
|
||||
try {
|
||||
// Lade-Animation erstellen
|
||||
const modal = createModalWithLoading('Beziehungen werden geladen...');
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Beziehungen laden
|
||||
const response = await fetch(`/api/thoughts/${thoughtId}/relations`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const relations = await response.json();
|
||||
|
||||
// Modal mit Beziehungen aktualisieren
|
||||
updateModalWithRelations(modal, relations, thoughtId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Beziehungen:', error);
|
||||
if (window.MindMap && window.MindMap.showNotification) {
|
||||
window.MindMap.showNotification('Fehler beim Laden der Beziehungen.', 'error');
|
||||
} else {
|
||||
alert('Fehler beim Laden der Beziehungen.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Erstellt ein Modal mit Lade-Animation
|
||||
*/
|
||||
function createModalWithLoading(loadingText) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 z-50 flex items-center justify-center p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="absolute inset-0 bg-black/50" id="modal-backdrop"></div>
|
||||
<div class="glass-effect relative rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto z-10">
|
||||
<div class="p-6 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-400"></div>
|
||||
</div>
|
||||
<p class="text-lg text-white">${loadingText}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event-Listener zum Schließen
|
||||
modal.querySelector('#modal-backdrop').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert das Modal mit Kommentaren
|
||||
*/
|
||||
function updateModalWithComments(modal, comments, thoughtId) {
|
||||
const modalContent = modal.querySelector('.glass-effect');
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold text-white">Kommentare</h3>
|
||||
<button id="close-modal-btn" class="text-gray-400 hover:text-white">
|
||||
<i class="fa-solid fa-xmark text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="comments-list mb-6 space-y-4">
|
||||
${comments.length === 0 ?
|
||||
'<div class="text-center text-gray-400 py-4">Keine Kommentare vorhanden.</div>' :
|
||||
comments.map(comment => `
|
||||
<div class="glass-effect p-3 rounded">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="font-medium text-white">${comment.author}</div>
|
||||
<div class="text-xs text-gray-400">${comment.timestamp}</div>
|
||||
</div>
|
||||
<p class="mt-2 text-gray-200">${comment.content}</p>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
<form id="comment-form" class="space-y-3">
|
||||
<div>
|
||||
<label for="comment-content" class="block text-sm font-medium text-gray-300">Neuer Kommentar</label>
|
||||
<textarea id="comment-content" name="content" rows="3" required
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border-dark-500 text-white"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fa-solid fa-paper-plane mr-2"></i> Senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event-Listener hinzufügen
|
||||
modalContent.querySelector('#close-modal-btn').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Kommentar-Formular
|
||||
const form = modalContent.querySelector('#comment-form');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const content = form.elements.content.value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
thought_id: thoughtId,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Speichern des Kommentars.');
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
modal.remove();
|
||||
|
||||
// Erfolgsbenachrichtigung
|
||||
MindMap.showNotification('Kommentar erfolgreich gespeichert.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Kommentars:', error);
|
||||
MindMap.showNotification('Fehler beim Speichern des Kommentars.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert das Modal mit Beziehungen
|
||||
*/
|
||||
function updateModalWithRelations(modal, relations, thoughtId) {
|
||||
const modalContent = modal.querySelector('.glass-effect');
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold text-white">Beziehungen</h3>
|
||||
<button id="close-modal-btn" class="text-gray-400 hover:text-white">
|
||||
<i class="fa-solid fa-xmark text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relations-list mb-6 space-y-4">
|
||||
${relations.length === 0 ?
|
||||
'<div class="text-center text-gray-400 py-4">Keine Beziehungen vorhanden.</div>' :
|
||||
relations.map(relation => `
|
||||
<div class="glass-effect p-3 rounded">
|
||||
<div class="flex items-center">
|
||||
<span class="inline-block px-2 py-1 rounded-full text-xs font-medium bg-primary-600 text-white">
|
||||
${relation.relation_type}
|
||||
</span>
|
||||
<div class="ml-3">
|
||||
<div class="text-white">Ziel: Gedanke #${relation.target_id}</div>
|
||||
<div class="text-xs text-gray-400">Erstellt von ${relation.created_by} am ${relation.created_at}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
<form id="relation-form" class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="target_id" class="block text-sm font-medium text-gray-300">Ziel-Gedanke ID</label>
|
||||
<input type="number" id="target_id" name="target_id" required
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border-dark-500 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label for="relation_type" class="block text-sm font-medium text-gray-300">Beziehungstyp</label>
|
||||
<select id="relation_type" name="relation_type" required
|
||||
class="mt-1 block w-full rounded-md bg-dark-700 border-dark-500 text-white">
|
||||
<option value="SUPPORTS">Stützt</option>
|
||||
<option value="CONTRADICTS">Widerspricht</option>
|
||||
<option value="BUILDS_UPON">Baut auf auf</option>
|
||||
<option value="GENERALIZES">Verallgemeinert</option>
|
||||
<option value="SPECIFIES">Spezifiziert</option>
|
||||
<option value="INSPIRES">Inspiriert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fa-solid fa-plus mr-2"></i> Beziehung erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event-Listener hinzufügen
|
||||
modalContent.querySelector('#close-modal-btn').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Beziehungs-Formular
|
||||
const form = modalContent.querySelector('#relation-form');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
source_id: thoughtId,
|
||||
target_id: parseInt(form.elements.target_id.value),
|
||||
relation_type: form.elements.relation_type.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/relations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Erstellen der Beziehung.');
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
modal.remove();
|
||||
|
||||
// Erfolgsbenachrichtigung
|
||||
MindMap.showNotification('Beziehung erfolgreich erstellt.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Beziehung:', error);
|
||||
MindMap.showNotification('Fehler beim Erstellen der Beziehung.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
844
static/js/modules/mindmap.js
Normal file
844
static/js/modules/mindmap.js
Normal file
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user