(() => { 'use strict'; const canvas = document.getElementById('brain'); const ctx = canvas.getContext('2d', {alpha: false}); const $ = (id) => document.getElementById(id); const GOLDEN = Math.PI * (3 - Math.sqrt(5)); const CORTEX_PALETTE = [ [82, 231, 255], [145, 126, 255], [255, 105, 178], [93, 255, 189], [255, 180, 82], [75, 143, 255], [196, 117, 255], [255, 102, 92], [135, 231, 105], [78, 207, 224], [235, 215, 98], [151, 205, 255] ]; const MODE_CONFIG = { living: {label: 'LIVING', detail: 'ruhige Eigenaktivität', className: 'living', color: [82, 231, 255], duration: 0}, thinking: {label: 'AI-THINK', detail: 'verknüpft und bewertet', className: 'thinking', color: [255, 180, 82], duration: 10500}, researching: {label: 'RESEARCH', detail: 'klärt Unsicherheiten', className: 'researching', color: [93, 255, 189], duration: 11500}, processing: {label: 'PROCESSING', detail: 'Agent- und Wissenssuche', className: 'processing', color: [183, 117, 255], duration: 7800}, learning: {label: 'LEARNING', detail: 'indexiert bei stabiler Ansicht', className: 'learning', color: [75, 123, 255], duration: 6500} }; const LOD_CONFIG = { localDistance: 0.085, coarseDistance: 0.23, localMaxMembers: 24, coarseMaxChildren: 18, revealNodeMS: 30000, revealParentMS: 42000, rebuildThrottleMS: 120 }; const state = { nodes: [], edges: [], clusters: [], clusterByKey: new Map(), nodeById: new Map(), edgeById: new Map(), adjacency: new Map(), active: new Map(), edgeActive: new Map(), particles: [], waves: [], pulses: 0, yaw: 0.18, pitch: -0.12, zoom: 1.02, autoRotate: true, labels: true, edgesVisible: true, cortexVisible: true, dragging: false, moved: false, lastX: 0, lastY: 0, hover: null, selected: null, projected: [], width: innerWidth, height: innerHeight, dpr: Math.min(devicePixelRatio || 1, 2), last: performance.now(), lastLogFingerprint: new Map(), recentImportant: [], clusterActive: new Map(), mode: 'living', modeUntil: 0, activityEnergy: 0, targetEnergy: 0, focusClusterKey: '', focusNodeID: '', cameraTargetYaw: null, cameraTargetPitch: null, zoomTarget: 1.02, nextAmbientAt: performance.now() + 1800, ambientCursor: 0, bursts: [], brainStatus: null, lodEnabled: true, lodLeaves: [], lodCoarse: [], lodGroupById: new Map(), lodLeafByNode: new Map(), lodCoarseByNode: new Map(), lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2, renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(), edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0}, fullSnapshot: null, runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural'}, availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, settingsOpen: false, settingsDraft: null, graphVersion: null, displaySignature: '' }; function resize() { state.width = innerWidth; state.height = innerHeight; state.dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.floor(state.width * state.dpr); canvas.height = Math.floor(state.height * state.dpr); canvas.style.width = state.width + 'px'; canvas.style.height = state.height + 'px'; ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); } addEventListener('resize', resize); resize(); async function api(path, options = {}) { const res = await fetch(path, {headers: {'Content-Type': 'application/json', ...(options.headers || {})}, ...options}); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } function categoryFilterMatches(node, categories) { if (!categories || categories.length === 0) return true; const wanted = new Set(categories.map(value => String(value).trim().toLowerCase()).filter(Boolean)); if (wanted.has('*')) return true; const nodeCategories = (node.categories || []).map(value => String(value).trim().toLowerCase()).filter(Boolean); if (nodeCategories.length === 0) return wanted.has('__uncategorized__'); return nodeCategories.some(category => wanted.has(category)); } function filteredSnapshot(snapshot) { const filters = state.runtimeSettings.display_categories || []; if (!filters.length) return snapshot; const visible = new Set(); const noteKinds = new Set(['knowledge', 'ai-think', 'external']); for (const node of snapshot.nodes) { if (noteKinds.has(node.kind) && categoryFilterMatches(node, filters)) visible.add(node.id); if (node.kind === 'category' && filters.some(value => String(value).toLowerCase() === String(node.label || '').toLowerCase())) visible.add(node.id); } for (const edge of snapshot.edges) { if (visible.has(edge.source)) visible.add(edge.target); if (visible.has(edge.target)) visible.add(edge.source); } return { ...snapshot, nodes: snapshot.nodes.filter(node => visible.has(node.id)), edges: snapshot.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)) }; } async function loadRuntimeConfiguration() { try { const [settings, categories] = await Promise.all([api('/api/runtime-settings'), api('/api/categories')]); state.runtimeSettings = {...state.runtimeSettings, ...settings}; state.availableCategories = categories.categories || []; state.viewMode = state.runtimeSettings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural'; syncRuntimeControls(); renderCategoryFilters(); } catch { syncRuntimeControls(); } } function syncRuntimeControls() { const learning = Boolean(state.runtimeSettings.learning_enabled); const thinking = Boolean(state.runtimeSettings.thinking_enabled); const panelSettings = state.settingsOpen && state.settingsDraft ? state.settingsDraft : state.runtimeSettings; const learningButton = $('toggleLearning'); const thinkingButton = $('toggleThinking'); if (learningButton) learningButton.classList.toggle('active', learning); if (thinkingButton) thinkingButton.classList.toggle('active', thinking); if ($('settingsLearning')) $('settingsLearning').checked = Boolean(panelSettings.learning_enabled); if ($('settingsThinking')) $('settingsThinking').checked = Boolean(panelSettings.thinking_enabled); if ($('settingsViewNeural')) $('settingsViewNeural').classList.toggle('active', panelSettings.view_mode !== 'honeycomb'); if ($('settingsViewHoneycomb')) $('settingsViewHoneycomb').classList.toggle('active', panelSettings.view_mode === 'honeycomb'); const enrichButton = $('enrichNow'); if (enrichButton && !state.brainStatus?.enrich_running) enrichButton.disabled = !thinking; if (!learning && !thinking) setVisualMode('living'); updateViewButtons(); } function categoryLabel(name) { return name === '__uncategorized__' ? 'Ohne Kategorie' : name; } function filterSet(key) { const source = state.settingsDraft || state.runtimeSettings; return new Set((source[`${key}_categories`] || []).map(value => String(value).toLowerCase())); } function renderCategoryFilters(search = '') { const query = String(search || '').trim().toLowerCase(); const targets = {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')}; for (const [key, target] of Object.entries(targets)) { if (!target) continue; const selected = filterSet(key); target.innerHTML = ''; const categories = state.availableCategories.filter(category => !query || categoryLabel(category.name).toLowerCase().includes(query)); for (const category of categories) { const label = document.createElement('label'); label.className = 'category-option'; const checked = selected.has(String(category.name).toLowerCase()); label.innerHTML = `${escapeHTML(categoryLabel(category.name))}${Number(category.count || 0).toLocaleString('de-DE')}`; target.appendChild(label); } if (!categories.length) target.innerHTML = 'Keine passende Kategorie'; } } function collectCategoryFilter(key) { return [...document.querySelectorAll(`[data-category-filter="${key}"]:checked`)].map(input => input.value); } async function persistRuntimeSettings(settings = state.runtimeSettings) { const normalized = { learning_enabled: Boolean(settings.learning_enabled), thinking_enabled: Boolean(settings.thinking_enabled), learning_categories: [...(settings.learning_categories || [])], display_categories: [...(settings.display_categories || [])], thinking_categories: [...(settings.thinking_categories || [])], view_mode: settings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural' }; const previousDisplay = JSON.stringify(state.runtimeSettings.display_categories || []); const updated = await api('/api/runtime-settings', {method: 'PUT', body: JSON.stringify(normalized)}); state.runtimeSettings = {...normalized, ...updated}; state.viewMode = state.runtimeSettings.view_mode; syncRuntimeControls(); applyViewMode(state.viewMode, false); if (previousDisplay !== JSON.stringify(state.runtimeSettings.display_categories || [])) await loadGraph(); await loadStatus(); return state.runtimeSettings; } function openSettingsPanel() { state.settingsOpen = true; state.settingsDraft = JSON.parse(JSON.stringify(state.runtimeSettings)); $('settingsPanel')?.classList.remove('hidden'); $('settingsBackdrop')?.classList.remove('hidden'); syncRuntimeControls(); renderCategoryFilters($('categorySearch')?.value || ''); } function closeSettingsPanel() { state.settingsOpen = false; state.settingsDraft = null; $('settingsPanel')?.classList.add('hidden'); $('settingsBackdrop')?.classList.add('hidden'); } async function loadGraph() { try { const snap = await api('/api/graph'); const displaySignature = JSON.stringify(state.runtimeSettings.display_categories || []); if (state.fullSnapshot && state.graphVersion === snap.version && state.displaySignature === displaySignature) return; state.graphVersion = snap.version; state.displaySignature = displaySignature; state.fullSnapshot = snap; const filtered = filteredSnapshot(snap); const old = state.nodeById; const oldClusters = state.clusterByKey; state.nodes = filtered.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''})); state.edges = filtered.edges; state.nodeById = new Map(state.nodes.map(n => [n.id, n])); state.edgeById = new Map(state.edges.map(e => [e.id, e])); state.adjacency = new Map(); for (const e of state.edges) { if (!state.adjacency.has(e.source)) state.adjacency.set(e.source, []); if (!state.adjacency.has(e.target)) state.adjacency.set(e.target, []); state.adjacency.get(e.source).push(e); state.adjacency.get(e.target).push(e); } buildLayout(old, oldClusters); for (const node of state.nodes) { node.neuralX = node.x; node.neuralY = node.y; node.neuralZ = node.z; } buildHoneycombLayout(); buildLODHierarchy(); applyViewMode(state.runtimeSettings.view_mode || state.viewMode, false); $('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE'); $('nodeCount').parentElement.title = `${state.nodes.length.toLocaleString('de-DE')} sichtbar · ${snap.nodes.length.toLocaleString('de-DE')} insgesamt`; $('edgeCount').textContent = state.edges.length.toLocaleString('de-DE'); } catch { setSystem('offline', false); } } function setSystem(text, ok = true) { $('systemState').lastChild.textContent = ' ' + text; $('systemState').style.color = ok ? 'var(--green)' : 'var(--red)'; } async function loadStatus() { try { const status = await api('/api/status'); state.brainStatus = status; if (status.runtime_settings) { state.runtimeSettings = {...state.runtimeSettings, ...status.runtime_settings}; syncRuntimeControls(); } renderAutonomyStatus(status); } catch { renderAutonomyStatus({ollama_ok: false, auto_enrich: false, enrich_error: 'Status nicht erreichbar'}); } } function renderAutonomyStatus(status) { const panel = $('autonomyStatus'); const button = $('enrichNow'); if (!panel || !button) return; const runtime = status.runtime_settings || state.runtimeSettings; const thinkingEnabled = runtime.thinking_enabled !== false; const learningEnabled = runtime.learning_enabled !== false; const queued = status.enrich_result === 'queued' && !status.enrich_running; const running = Boolean(status.enrich_running || queued); let text = ''; let cls = 'waiting'; if (!thinkingEnabled) { text = `Living-only · Thinking pausiert${learningEnabled ? '' : ' · Learning pausiert'}.`; cls = 'waiting'; } else if (status.enrich_running) { text = `AI-THINK läuft · ${status.enrich_trigger === 'manual' ? 'manuell' : 'automatisch'} · Batch ${status.enrich_batch_size || 1}`; cls = 'running'; } else if (queued) { text = 'AI-THINK ist eingeplant und wartet auf den sequenziellen Worker.'; cls = 'running'; } else if (!status.ollama_ok) { text = 'Ollama/Qwen derzeit nicht bestätigt · ein manueller Start versucht die Verbindung erneut.'; cls = 'offline'; } else if (!status.auto_enrich) { text = 'Automatik ist deaktiviert · AI-THINK kann manuell gestartet werden.'; cls = 'waiting'; } else if (status.enrich_result === 'no_candidate') { text = `Im zuletzt geprüften Graphbereich kein Kandidat über dem Schwellwert · nächster Versuch ${nextRunText(status.next_enrich)}.`; cls = 'waiting'; } else { text = `Autonom aktiv · ${status.enrich_batch_size || 1} Prüfungen pro Zyklus · nächster Lauf ${nextRunText(status.next_enrich)}.`; cls = 'waiting'; } panel.className = `autonomy-status ${cls}`; const pool = status.ollama_pool || {}; const persistence = status.persistence || {}; const glpi = status.glpi_kb || {}; const diagnostics = [`Ollama ${pool.healthy_nodes ?? 0}/${pool.node_count ?? 0}`, `Diskqueue ${persistence.pending_files ?? 0}`]; if (glpi.enabled) diagnostics.push(`GLPI-KB ${glpi.documents ?? 0}`); panel.title = diagnostics.join(' · '); const span = panel.querySelector('span'); if (span) span.textContent = text; button.disabled = running || !thinkingEnabled; button.classList.toggle('running', running); const small = button.querySelector('small'); if (small) small.textContent = !thinkingEnabled ? 'PAUSIERT' : status.enrich_running ? 'LÄUFT' : queued ? 'WARTET' : 'STARTEN'; } function nextRunText(value) { const when = new Date(value || 0).getTime(); if (!Number.isFinite(when) || when < Date.now() - 1000) return 'bald'; const seconds = Math.max(0, Math.round((when - Date.now()) / 1000)); if (seconds < 60) return `in ${seconds}s`; return `in ${Math.floor(seconds / 60)}m ${seconds % 60}s`; } function hashString(value) { let h = 2166136261; const s = String(value || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function fract(v) { return v - Math.floor(v); } function pseudo(value, offset = 0) { const h = hashString(value + ':' + offset); return fract(Math.sin(h * 0.00000137 + offset * 12.345) * 43758.5453123); } function hsvToRgb(h, s, v) { const c = v * s; const hh = (h % 360) / 60; const x = c * (1 - Math.abs(hh % 2 - 1)); let r = 0, g = 0, b = 0; if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0]; else if (hh < 2) [r, g, b] = [x, c, 0]; else if (hh < 3) [r, g, b] = [0, c, x]; else if (hh < 4) [r, g, b] = [0, x, c]; else if (hh < 5) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const m = v - c; return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; } function explicitClusterKey(node) { const cats = (node.categories || []).filter(Boolean); const preferred = cats.find(c => !/^(AI-THINK|AI-Staging|Staging|Produktiv)$/i.test(c)); if (preferred) return preferred; if (node.kind === 'category' && node.label) return node.label; if (node.kind === 'ai-think') return 'AI-THINK'; return ''; } function categoryKey(node) { const explicit = explicitClusterKey(node); if (explicit) return explicit; if (node.kind === 'external') return 'Recherche'; if (node.kind === 'source') return 'Quellen'; if (node.kind === 'concept') return 'Konzepte'; return `${node.kind || 'node'}:${node.status || 'live'}`; } function clusterColorFor(key) { const hue = hashString(key) % 360; return hsvToRgb(hue, 0.48, 1); } function nodeColor(node, alpha = 1) { let c = node.clusterColor || [82, 231, 255]; if (node.kind === 'ai-think' || (node.categories || []).some(x => String(x).toUpperCase() === 'AI-THINK')) c = [255, 180, 82]; else if (node.status === 'staging') c = [183, 117, 255]; else if (node.kind === 'external') c = [93, 255, 189]; else if (node.kind === 'category') c = [75, 123, 255]; else if (node.kind === 'source') c = [255, 95, 136]; return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`; } function clusterFill(cluster, alpha = 1) { const c = cluster.color || [82, 231, 255]; return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`; } function insideBrain(x, y, z) { const fissure = Math.abs(x) < 0.045 && y > -0.58 && y < 0.42; if (fissure) return false; const taperY = y + Math.abs(z) * 0.10 - Math.max(0, Math.abs(x) - 0.58) * 0.18; const lx = (x + 0.35) / 0.58; const rx = (x - 0.35) / 0.58; const ny = taperY / 0.76; const nz = z / 0.58; const left = lx * lx + ny * ny + nz * nz <= 1; const right = rx * rx + ny * ny + nz * nz <= 1; const stemCut = y < -0.76 || y > 0.82; return !stemCut && (left || right); } function clampBrain(point, sideHint) { const out = {x: point.x, y: point.y, z: point.z}; const centerX = sideHint === 'left' ? -0.36 : sideHint === 'right' ? 0.36 : (out.x < 0 ? -0.36 : 0.36); for (let i = 0; i < 18 && !insideBrain(out.x, out.y, out.z); i++) { out.x = centerX + (out.x - centerX) * 0.88; out.y *= 0.93; out.z *= 0.93; } if (sideHint === 'left' && out.x > -0.06) out.x = -0.06 - Math.abs(out.x) * 0.1; if (sideHint === 'right' && out.x < 0.06) out.x = 0.06 + Math.abs(out.x) * 0.1; out.x = Math.max(-0.95, Math.min(0.95, out.x)); out.y = Math.max(-0.9, Math.min(0.9, out.y)); out.z = Math.max(-0.72, Math.min(0.72, out.z)); return out; } function honeycombPointCount(spacing, stopAt = Infinity, collect = false) { const points = collect ? [] : null; let count = 0; const yStep = spacing * Math.sqrt(3) / 2; const zStep = spacing * Math.sqrt(2 / 3); const kMin = Math.floor(-0.72 / zStep) - 1; const kMax = Math.ceil(0.72 / zStep) + 1; const rMin = Math.floor(-0.9 / yStep) - 1; const rMax = Math.ceil(0.9 / yStep) + 1; for (let k = kMin; k <= kMax; k++) { const layer = Math.abs(k) % 2; const z = k * zStep; const layerX = layer * spacing * 0.5; const layerY = layer * yStep / 3; for (let r = rMin; r <= rMax; r++) { const y = r * yStep + layerY; const rowX = (Math.abs(r) % 2) * spacing * 0.5; const qMin = Math.floor(-1.02 / spacing) - 1; const qMax = Math.ceil(1.02 / spacing) + 1; for (let q = qMin; q <= qMax; q++) { const x = q * spacing + rowX + layerX; if (!insideBrain(x, y, z)) continue; count++; if (collect) points.push({x, y, z}); if (count >= stopAt) return collect ? points : count; } } } return collect ? points : count; } function buildHoneycombLayout() { const noteKinds = new Set(['knowledge', 'ai-think', 'external']); const notes = state.nodes.filter(node => noteKinds.has(node.kind)); state.honeycombNodes = notes; if (!notes.length) { state.honeycombSpacing = 0; return; } let low = 0.008; let high = 0.32; while (honeycombPointCount(low, notes.length) < notes.length && low > 0.0025) low *= 0.75; for (let i = 0; i < 12; i++) { const mid = (low + high) / 2; const count = honeycombPointCount(mid, notes.length); if (count >= notes.length) low = mid; else high = mid; } const spacing = Math.max(0.0025, low * 0.985); let points = honeycombPointCount(spacing, Infinity, true); if (points.length < notes.length) points = honeycombPointCount(Math.max(0.0025, spacing * 0.96), Infinity, true); points.sort((a, b) => hashString(`${a.x.toFixed(5)}:${a.y.toFixed(5)}:${a.z.toFixed(5)}`) - hashString(`${b.x.toFixed(5)}:${b.y.toFixed(5)}:${b.z.toFixed(5)}`)); notes.sort((a, b) => hashString(a.id) - hashString(b.id)); const step = points.length / notes.length; for (let i = 0; i < notes.length; i++) { const point = points[Math.min(points.length - 1, Math.floor(i * step))]; notes[i].honeyX = point.x; notes[i].honeyY = point.y; notes[i].honeyZ = point.z; } state.honeycombSpacing = spacing; } function updateViewButtons() { const neural = $('viewNeural'); const honey = $('viewHoneycomb'); if (neural) neural.classList.toggle('active', state.viewMode === 'neural'); if (honey) honey.classList.toggle('active', state.viewMode === 'honeycomb'); document.body.classList.toggle('honeycomb-view', state.viewMode === 'honeycomb'); const edgeButton = $('toggleEdges'); const cortexButton = $('toggleCortex'); const lodButton = $('toggleLOD'); for (const button of [edgeButton, cortexButton, lodButton]) { if (button) button.disabled = state.viewMode === 'honeycomb'; } } function applyViewMode(mode, persist = true) { mode = mode === 'honeycomb' ? 'honeycomb' : 'neural'; state.viewMode = mode; state.runtimeSettings.view_mode = mode; state.hover = null; state.selected = null; state.particles.length = 0; state.edgeRenderMap.clear(); for (const node of state.nodes) { node.transitionFrom = null; node.transitionStart = 0; } if (mode === 'honeycomb') { state.renderNodes = state.honeycombNodes; state.renderEdges = []; state.renderIdleEdges = []; state.renderNodeById = new Map(state.honeycombNodes.map(node => [node.id, node])); state.renderEdgeById = new Map(); state.visibleForNode = new Map(state.honeycombNodes.map(node => [node.id, node.id])); state.renderStats = {nodes: state.honeycombNodes.length, edges: 0, hiddenNodes: Math.max(0, state.nodes.length - state.honeycombNodes.length), hiddenEdges: state.edges.length}; const renderCount = $('renderCount'); if (renderCount) renderCount.textContent = state.honeycombNodes.length.toLocaleString('de-DE'); } else { state.lodDirty = true; rebuildRenderGraph(performance.now(), true); } updateViewButtons(); if (persist) persistRuntimeSettings().catch(() => {}); } function buildLayout(previousNodes = new Map(), previousClusters = new Map()) { const degree = new Map(state.nodes.map(n => [n.id, (state.adjacency.get(n.id) || []).length])); const labels = new Map(); const locked = new Set(); for (const node of state.nodes) { const explicit = explicitClusterKey(node); if (explicit) { labels.set(node.id, explicit); locked.add(node.id); } } for (let round = 0; round < 4; round++) { const pending = []; for (const node of state.nodes) { if (locked.has(node.id)) continue; const votes = new Map(); for (const edge of state.adjacency.get(node.id) || []) { const otherID = edge.source === node.id ? edge.target : edge.source; const label = labels.get(otherID); if (!label) continue; const trust = edge.origin === 'ai-inference' ? 0.72 : edge.type === 'categorized_as' ? 1.35 : 1; const score = Math.max(0.05, edge.weight || 1) * trust; votes.set(label, (votes.get(label) || 0) + score); } let best = '', bestScore = 0; for (const [label, score] of votes) { if (score > bestScore) { best = label; bestScore = score; } } if (best) pending.push([node.id, best]); } for (const [id, label] of pending) labels.set(id, label); if (!pending.length) break; } const clusters = new Map(); for (const n of state.nodes) { const key = labels.get(n.id) || categoryKey(n); n.clusterKey = key; let cluster = clusters.get(key); if (!cluster) { const previous = previousClusters.get(key); cluster = {key, label: key, nodes: [], size: 0, mass: 0, color: clusterColorFor(key), x: previous?.x || 0, y: previous?.y || 0, z: previous?.z || 0, side: previous?.side || 'left', radius: 0.12, links: new Map(), hasPrevious: Boolean(previous)}; clusters.set(key, cluster); } cluster.nodes.push(n); cluster.size++; cluster.mass += 1 + Math.min(10, Math.sqrt(degree.get(n.id) || 0)); } for (const e of state.edges) { const a = state.nodeById.get(e.source); const b = state.nodeById.get(e.target); if (!a || !b || a.clusterKey === b.clusterKey) continue; const c1 = clusters.get(a.clusterKey); const c2 = clusters.get(b.clusterKey); c1.links.set(c2.key, (c1.links.get(c2.key) || 0) + (e.weight || 1)); c2.links.set(c1.key, (c2.links.get(c1.key) || 0) + (e.weight || 1)); } const clusterList = Array.from(clusters.values()).sort((a, b) => b.mass - a.mass || a.key.localeCompare(b.key)); clusterList.forEach((cluster, index) => { cluster.color = CORTEX_PALETTE[index % CORTEX_PALETTE.length]; cluster.importance = index < 12 ? 1 : Math.max(0.25, 1 - index / Math.max(1, clusterList.length)); cluster.screen = null; }); const left = [], right = []; let leftMass = 0, rightMass = 0; for (const cluster of clusterList) { let chooseLeft; if (cluster.hasPrevious) { chooseLeft = cluster.side === 'left'; } else { const preferLeft = (hashString(cluster.key) % 2) === 0; chooseLeft = Math.abs(leftMass - rightMass) > cluster.mass * 0.35 ? leftMass <= rightMass : preferLeft; cluster.side = chooseLeft ? 'left' : 'right'; } if (chooseLeft) { left.push(cluster); leftMass += cluster.mass; } else { right.push(cluster); rightMass += cluster.mass; } cluster.radius = Math.max(0.08, Math.min(0.24, 0.08 + Math.sqrt(cluster.size) / 75)); } seedHemis(left, -1); seedHemis(right, 1); relaxClusters(clusterList); placeNodes(clusterList, degree, previousNodes); state.clusters = clusterList; state.clusterByKey = new Map(clusterList.map(cluster => [cluster.key, cluster])); } function seedHemis(list, sign) { const total = Math.max(1, list.length); for (let i = 0; i < list.length; i++) { const cluster = list[i]; if (cluster.hasPrevious) continue; const t = (i + 0.6) / total; const ring = Math.sqrt(t); const angle = i * GOLDEN; const x = sign * (0.18 + 0.26 * (0.25 + ring * 0.75)); const y = Math.cos(angle) * 0.56 * ring; const z = Math.sin(angle) * 0.46 * ring; Object.assign(cluster, clampBrain({x, y, z}, sign < 0 ? 'left' : 'right')); } } function relaxClusters(clusterList) { for (let iter = 0; iter < 70; iter++) { const force = clusterList.map(() => ({x: 0, y: 0, z: 0})); for (let i = 0; i < clusterList.length; i++) { const a = clusterList[i]; for (let j = i + 1; j < clusterList.length; j++) { const b = clusterList[j]; let dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; const dist = Math.max(0.001, Math.hypot(dx, dy, dz)); const ux = dx / dist, uy = dy / dist, uz = dz / dist; const minDist = a.radius + b.radius + 0.06; const repulsion = 0.0016 * Math.sqrt(a.mass * b.mass) / (dist * dist); force[i].x -= ux * repulsion; force[i].y -= uy * repulsion; force[i].z -= uz * repulsion; force[j].x += ux * repulsion; force[j].y += uy * repulsion; force[j].z += uz * repulsion; if (dist < minDist) { const push = (minDist - dist) * 0.022; force[i].x -= ux * push; force[i].y -= uy * push; force[i].z -= uz * push; force[j].x += ux * push; force[j].y += uy * push; force[j].z += uz * push; } const linkWeight = (a.links.get(b.key) || 0); if (linkWeight > 0) { const target = a.side === b.side ? 0.24 + Math.max(a.radius, b.radius) * 0.35 : 0.38 + Math.max(a.radius, b.radius) * 0.25; const spring = (dist - target) * 0.0022 * Math.log1p(linkWeight); force[i].x += ux * spring; force[i].y += uy * spring; force[i].z += uz * spring; force[j].x -= ux * spring; force[j].y -= uy * spring; force[j].z -= uz * spring; } } } for (let i = 0; i < clusterList.length; i++) { const cluster = clusterList[i]; const fx = force[i]; const centerX = cluster.side === 'left' ? -0.34 : 0.34; fx.x += (centerX - cluster.x) * 0.012; fx.y += (-0.02 - cluster.y) * 0.01; fx.z += (0 - cluster.z) * 0.008; const stability = cluster.hasPrevious ? 0.18 : 1; cluster.x += Math.max(-0.03, Math.min(0.03, fx.x)) * stability; cluster.y += Math.max(-0.03, Math.min(0.03, fx.y)) * stability; cluster.z += Math.max(-0.03, Math.min(0.03, fx.z)) * stability; Object.assign(cluster, clampBrain(cluster, cluster.side)); } } } function placeNodes(clusterList, degree, previousNodes = new Map()) { for (const cluster of clusterList) { cluster.nodes.sort((a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || a.id.localeCompare(b.id)); const total = Math.max(1, cluster.nodes.length); for (let i = 0; i < cluster.nodes.length; i++) { const node = cluster.nodes[i]; node.clusterColor = cluster.color; node.cluster = cluster; const rank = i / total; const spread = Math.pow(rank, 0.58); const angleA = i * GOLDEN + pseudo(node.id, 1) * Math.PI * 2; const angleB = Math.acos(1 - 2 * pseudo(node.id, 2)); const shell = cluster.radius * (0.18 + 0.92 * spread); const density = 0.75 + pseudo(node.id, 3) * 0.5; let ox = Math.cos(angleA) * Math.sin(angleB) * shell * density; let oy = Math.sin(angleA) * Math.sin(angleB) * shell * density * 0.95; let oz = Math.cos(angleB) * shell * (0.66 + pseudo(node.id, 4) * 0.24); ox += (cluster.side === 'left' ? -1 : 1) * (0.015 + (1 - spread) * 0.018); const pos = clampBrain({x: cluster.x + ox, y: cluster.y + oy, z: cluster.z + oz}, cluster.side); const previous = previousNodes.get(node.id); const preserve = previous?.clusterKey === cluster.key ? 0.90 : 0; node.x = previous ? previous.x * preserve + pos.x * (1 - preserve) : pos.x; node.y = previous ? previous.y * preserve + pos.y * (1 - preserve) : pos.y; node.z = previous ? previous.z * preserve + pos.z * (1 - preserve) : pos.z; } } } function splitSpatial(items, maxItems) { if (items.length <= maxItems) return [items]; const ranges = ['x', 'y', 'z'].map(axis => { let min = Infinity, max = -Infinity; for (const item of items) { min = Math.min(min, item[axis]); max = Math.max(max, item[axis]); } return {axis, range: max - min}; }).sort((a, b) => b.range - a.range); const axis = ranges[0].axis; const sorted = [...items].sort((a, b) => a[axis] - b[axis] || String(a.id).localeCompare(String(b.id))); const parts = []; for (let i = 0; i < sorted.length; i += maxItems) parts.push(sorted.slice(i, i + maxItems)); return parts; } function spatialBuckets(items, cellSize, maxItems) { const buckets = new Map(); for (const item of items) { const key = `${Math.floor(item.x / cellSize)}:${Math.floor(item.y / cellSize)}:${Math.floor(item.z / cellSize)}`; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(item); } const result = []; const entries = [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0])); for (const [, bucket] of entries) result.push(...splitSpatial(bucket, maxItems)); return result; } function makeLODGroup(level, cluster, members, children, ordinal) { let sumX = 0, sumY = 0, sumZ = 0, mass = 0; for (const member of members) { const weight = 1 + Math.min(8, Math.sqrt((state.adjacency.get(member.id) || []).length)); sumX += member.x * weight; sumY += member.y * weight; sumZ += member.z * weight; mass += weight; } const signature = `${cluster.key}|${level}|${members[0]?.id || ordinal}|${members.length}|${ordinal}`; const id = `lod:${level}:${hashString(signature).toString(36)}`; const statuses = new Set(members.map(member => member.status).filter(Boolean)); return { id, kind: 'supernode', renderKind: 'supernode', lodLevel: level, clusterKey: cluster.key, cluster, label: cluster.label, summary: `${members.length.toLocaleString('de-DE')} verdichtete Wissenselemente`, status: statuses.size === 1 ? [...statuses][0] : 'aggregate', origin: 'lod', categories: [cluster.label], x: sumX / Math.max(1, mass), y: sumY / Math.max(1, mass), z: sumZ / Math.max(1, mass), weight: Math.max(1, Math.log2(members.length + 1)), clusterColor: cluster.color, members, memberIDs: members.map(member => member.id), memberCount: members.length, children: children || [], internalEdgeCount: 0, externalEdgeCount: 0, screen: null, transitionFrom: null, transitionStart: 0 }; } function buildLODHierarchy() { state.lodLeaves = []; state.lodCoarse = []; state.lodGroupById = new Map(); state.lodLeafByNode = new Map(); state.lodCoarseByNode = new Map(); for (const cluster of state.clusters) { const localParts = spatialBuckets(cluster.nodes, LOD_CONFIG.localDistance, LOD_CONFIG.localMaxMembers); const leaves = localParts.map((members, index) => makeLODGroup(1, cluster, members, [], index)); for (const leaf of leaves) { state.lodLeaves.push(leaf); state.lodGroupById.set(leaf.id, leaf); for (const id of leaf.memberIDs) state.lodLeafByNode.set(id, leaf); } const coarseParts = spatialBuckets(leaves, LOD_CONFIG.coarseDistance, LOD_CONFIG.coarseMaxChildren); const coarseGroups = coarseParts.map((children, index) => { const members = children.flatMap(child => child.members); const group = makeLODGroup(2, cluster, members, children, index); for (const child of children) child.parentID = group.id; return group; }); cluster.lodLeaves = leaves; cluster.lodCoarse = coarseGroups; for (const coarse of coarseGroups) { state.lodCoarse.push(coarse); state.lodGroupById.set(coarse.id, coarse); for (const id of coarse.memberIDs) state.lodCoarseByNode.set(id, coarse); } } state.lodDirty = true; } function lodGroupOpen(group, now) { return (state.lodOpenUntil.get(group.id) || 0) > now; } function nodeHot(id, now) { return (state.lodHotUntil.get(id) || 0) > now || (state.active.get(id) || 0) > 0.04 || state.focusNodeID === id || state.selected?.id === id; } function groupHasHotNode(group, now) { if (lodGroupOpen(group, now)) return true; for (const id of group.memberIDs) if (nodeHot(id, now)) return true; return false; } function revealLODNodes(ids, duration = LOD_CONFIG.revealNodeMS) { const now = performance.now(); for (const id of ids || []) { if (!state.nodeById.has(id)) continue; state.lodHotUntil.set(id, Math.max(state.lodHotUntil.get(id) || 0, now + duration)); const leaf = state.lodLeafByNode.get(id); const coarse = state.lodCoarseByNode.get(id); if (leaf) state.lodOpenUntil.set(leaf.id, Math.max(state.lodOpenUntil.get(leaf.id) || 0, now + duration)); if (coarse) state.lodOpenUntil.set(coarse.id, Math.max(state.lodOpenUntil.get(coarse.id) || 0, now + Math.max(duration, LOD_CONFIG.revealParentMS))); } state.lodDirty = true; } function openLODGroup(group) { const now = performance.now(); const duration = group.lodLevel === 2 ? LOD_CONFIG.revealParentMS : LOD_CONFIG.revealNodeMS; state.lodOpenUntil.set(group.id, now + duration); if (group.lodLevel === 1) { const parent = state.lodGroupById.get(group.parentID); if (parent) state.lodOpenUntil.set(parent.id, now + LOD_CONFIG.revealParentMS); } state.lodDirty = true; rebuildRenderGraph(now, true); } function updateLODZoomBand() { const before = state.lodZoomBand; if (state.lodZoomBand === 2 && state.zoom > 1.12) state.lodZoomBand = 1; else if (state.lodZoomBand === 1 && state.zoom < 0.96) state.lodZoomBand = 2; else if (state.lodZoomBand === 1 && state.zoom > 1.72) state.lodZoomBand = 0; else if (state.lodZoomBand === 0 && state.zoom < 1.48) state.lodZoomBand = 1; if (before !== state.lodZoomBand) state.lodDirty = true; } function addVisibleEntity(entity, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now) { entity.renderKind = entity.kind === 'supernode' ? 'supernode' : 'node'; entity.memberCount = entity.kind === 'supernode' ? entity.memberCount : 1; entity.internalEdgeCount = 0; entity.externalEdgeCount = 0; const sampleID = entity.kind === 'supernode' ? entity.memberIDs[0] : entity.id; const oldVisibleID = oldVisibleForNode.get(sampleID); const oldEntity = oldVisibleID ? oldRenderNodeById.get(oldVisibleID) : null; if (!oldRenderNodeById.has(entity.id) && oldEntity) { entity.transitionFrom = {x: oldEntity.x, y: oldEntity.y, z: oldEntity.z}; entity.transitionStart = now; } renderNodes.push(entity); renderNodeById.set(entity.id, entity); if (entity.kind === 'supernode') { for (const id of entity.memberIDs) visibleForNode.set(id, entity.id); } else { visibleForNode.set(entity.id, entity.id); } } function rebuildRenderGraph(now = performance.now(), force = false) { if (!force && (!state.lodDirty || now - state.lodLastBuild < LOD_CONFIG.rebuildThrottleMS)) return; const oldRenderNodeById = state.renderNodeById; const oldVisibleForNode = state.visibleForNode; const renderNodes = []; const renderNodeById = new Map(); const visibleForNode = new Map(); if (!state.lodEnabled) { for (const node of state.nodes) addVisibleEntity(node, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now); } else { for (const coarse of state.lodCoarse) { const focused = coarse.clusterKey === state.focusClusterKey; const coarseOpen = state.lodZoomBand <= 1 || focused || groupHasHotNode(coarse, now); if (!coarseOpen && coarse.memberCount > 1) { addVisibleEntity(coarse, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now); continue; } for (const leaf of coarse.children) { const leafOpen = state.lodZoomBand === 0 || groupHasHotNode(leaf, now); if (!leafOpen && leaf.memberCount > 1) { addVisibleEntity(leaf, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now); } else { for (const node of leaf.members) addVisibleEntity(node, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now); } } } } const renderEdgesByKey = new Map(); const edgeRenderMap = new Map(); for (const edge of state.edges) { const source = visibleForNode.get(edge.source); const target = visibleForNode.get(edge.target); if (!source || !target) continue; if (source === target) { const entity = renderNodeById.get(source); if (entity) entity.internalEdgeCount++; edgeRenderMap.set(edge.id, ''); continue; } const key = `${source}\u0000${target}\u0000${edge.type || ''}\u0000${edge.origin || ''}\u0000${edge.status || ''}`; let aggregate = renderEdgesByKey.get(key); if (!aggregate) { aggregate = { id: `lod-edge:${hashString(key).toString(36)}`, source, target, type: edge.type, origin: edge.origin, status: edge.status, confidence: edge.confidence || 0, weight: 0, edgeCount: 0, weightSum: 0, maxConfidence: edge.confidence || 0, memberEdgeIDs: [], explanation: edge.explanation || '' }; renderEdgesByKey.set(key, aggregate); } aggregate.edgeCount++; aggregate.weightSum += edge.weight || 1; aggregate.maxConfidence = Math.max(aggregate.maxConfidence, edge.confidence || 0); aggregate.confidence = aggregate.maxConfidence; aggregate.weight = Math.log1p(aggregate.weightSum); if (aggregate.memberEdgeIDs.length < 32) aggregate.memberEdgeIDs.push(edge.id); edgeRenderMap.set(edge.id, aggregate.id); } const renderEdges = [...renderEdgesByKey.values()]; for (const edge of renderEdges) { const source = renderNodeById.get(edge.source); const target = renderNodeById.get(edge.target); if (source) source.externalEdgeCount += edge.edgeCount; if (target) target.externalEdgeCount += edge.edgeCount; } state.renderNodes = renderNodes; state.renderEdges = renderEdges; state.renderIdleEdges = renderEdges.filter(edge => edge.edgeCount > 1 || edge.origin === 'ai-inference' || edge.type === 'contradicts' || edge.type === 'supports'); state.renderNodeById = renderNodeById; state.renderEdgeById = new Map(renderEdges.map(edge => [edge.id, edge])); state.visibleForNode = visibleForNode; state.edgeRenderMap = edgeRenderMap; state.renderActive = new Map(); for (const [id, strength] of state.active) { const visibleID = visibleForNode.get(id); if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, strength)); } state.renderEdgeActive = new Map(); for (const [id, strength] of state.edgeActive) { const visibleID = edgeRenderMap.get(id); if (visibleID) state.renderEdgeActive.set(visibleID, Math.max(state.renderEdgeActive.get(visibleID) || 0, strength)); } state.renderStats = { nodes: renderNodes.length, edges: renderEdges.length, hiddenNodes: Math.max(0, state.nodes.length - renderNodes.length), hiddenEdges: Math.max(0, state.edges.length - renderEdges.length) }; const renderCount = $('renderCount'); if (renderCount) { renderCount.textContent = renderNodes.length.toLocaleString('de-DE'); renderCount.parentElement.title = `${renderNodes.length.toLocaleString('de-DE')} von ${state.nodes.length.toLocaleString('de-DE')} Nodes · ${renderEdges.length.toLocaleString('de-DE')} aggregierte Edges`; } const lodButton = $('toggleLOD'); if (lodButton) lodButton.title = `Hierarchisches LOD: ${renderNodes.length.toLocaleString('de-DE')} Render-Nodes / ${renderEdges.length.toLocaleString('de-DE')} Render-Edges`; state.lodLastBuild = now; state.lodDirty = false; state.lodNextExpiry = Infinity; for (const expires of state.lodOpenUntil.values()) if (expires > now) state.lodNextExpiry = Math.min(state.lodNextExpiry, expires); for (const expires of state.lodHotUntil.values()) if (expires > now) state.lodNextExpiry = Math.min(state.lodNextExpiry, expires); if (!Number.isFinite(state.lodNextExpiry)) state.lodNextExpiry = 0; if (state.hover && !renderNodeById.has(state.hover.id)) state.hover = null; } function updateLOD(now) { updateLODZoomBand(); if (state.lodNextExpiry && now >= state.lodNextExpiry) state.lodDirty = true; if (state.lodDirty) rebuildRenderGraph(now); } function rotatePoint(n) { const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw); const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch); const x1 = n.x * cy - n.z * sy; const z1 = n.x * sy + n.z * cy; const y1 = n.y * cp - z1 * sp; const z2 = n.y * sp + z1 * cp; return {x: x1, y: y1, z: z2}; } function project(n) { const r = rotatePoint(n); const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0; const centerX = state.width / 2 + panelOffset; const centerY = state.height / 2 - 4; const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40)); const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom; const perspective = 2.9 / (3.3 - r.z * 0.42); return {x: centerX + r.x * scale * perspective, y: centerY + r.y * scale * perspective, z: r.z, p: perspective}; } function rgba(color, alpha) { return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`; } function nearestAngle(current, target) { let delta = (target - current + Math.PI) % (Math.PI * 2) - Math.PI; if (delta < -Math.PI) delta += Math.PI * 2; return current + delta; } function setVisualMode(mode, evt = null, strength = 1) { if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) mode = 'living'; if (mode === 'learning' && !state.runtimeSettings.learning_enabled) mode = 'living'; if ((mode === 'thinking' || mode === 'researching') && !state.runtimeSettings.thinking_enabled) mode = 'living'; const cfg = MODE_CONFIG[mode] || MODE_CONFIG.living; const now = performance.now(); state.mode = mode; state.modeUntil = cfg.duration ? Math.max(state.modeUntil, now + cfg.duration) : 0; state.targetEnergy = mode === 'living' ? 0.08 : mode === 'learning' ? Math.min(0.72, 0.36 + strength * 0.22) : Math.min(1.35, 0.62 + strength * 0.58); state.zoomTarget = mode === 'living' || mode === 'learning' ? 1.02 : mode === 'thinking' ? 1.13 : mode === 'researching' ? 1.10 : 1.08; if (mode === 'learning') { state.focusClusterKey = ''; state.focusNodeID = ''; state.cameraTargetYaw = null; state.cameraTargetPitch = null; } const badge = $('visualMode'); if (badge) { badge.className = `mode-status ${cfg.className}`; const b = badge.querySelector('b'); const small = badge.querySelector('small'); if (b) b.textContent = cfg.label; if (small) small.textContent = cfg.detail; } if (mode !== 'learning' && evt?.node_ids?.length) { const focus = state.nodeById.get(evt.node_ids[0]); if (focus) focusCamera(focus); } } function focusCamera(node) { state.focusNodeID = node.id; state.focusClusterKey = node.clusterKey || ''; const x = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX) ? node.honeyX : node.x; const y = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyY) ? node.honeyY : node.y; const z = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyZ) ? node.honeyZ : node.z; const radial = Math.max(0.05, Math.hypot(x, z)); state.cameraTargetYaw = nearestAngle(state.yaw, Math.atan2(x, z)); state.cameraTargetPitch = Math.max(-0.62, Math.min(0.62, Math.atan2(y, radial))); } function updateVisualState(now, dt) { if (state.mode !== 'living' && now > state.modeUntil) { state.mode = 'living'; state.focusClusterKey = ''; state.focusNodeID = ''; state.cameraTargetYaw = null; state.cameraTargetPitch = null; setVisualMode('living'); } state.activityEnergy += (state.targetEnergy - state.activityEnergy) * Math.min(1, dt * 3.1); if (state.mode === 'living') state.targetEnergy = 0.06 + Math.sin(now * 0.00043) * 0.025; state.zoom += (state.zoomTarget - state.zoom) * Math.min(1, dt * 2.4); if (!state.dragging && state.cameraTargetYaw !== null && state.mode !== 'living') { state.yaw += (state.cameraTargetYaw - state.yaw) * Math.min(1, dt * 0.68); state.pitch += (state.cameraTargetPitch - state.pitch) * Math.min(1, dt * 0.68); } else if (state.autoRotate && !state.dragging) { const speed = state.mode === 'living' ? 0.026 : state.mode === 'learning' ? 0 : 0.009; state.yaw += dt * speed; } for (const [key, value] of state.clusterActive) { const next = value - dt * (state.mode === 'living' ? 0.16 : 0.23); if (next <= 0) state.clusterActive.delete(key); else state.clusterActive.set(key, next); } autonomousLivingPulse(now); } function autonomousLivingPulse(now) { if (state.viewMode === 'honeycomb') return; if (state.mode !== 'living' || now < state.nextAmbientAt || !state.clusters.length) return; const candidates = state.clusters.slice(0, Math.min(18, state.clusters.length)); const cluster = candidates[state.ambientCursor % candidates.length]; state.ambientCursor++; state.nextAmbientAt = now + 2400 + pseudo(cluster.key, state.ambientCursor) * 3600; const count = Math.min(cluster.nodes.length, 3 + Math.floor(pseudo(cluster.key, state.ambientCursor + 1) * 4)); state.clusterActive.set(cluster.key, Math.max(state.clusterActive.get(cluster.key) || 0, 0.34)); for (let i = 0; i < count; i++) { const node = cluster.nodes[(state.ambientCursor * 7 + i * 13) % cluster.nodes.length]; state.active.set(node.id, Math.max(state.active.get(node.id) || 0, 0.2 + pseudo(node.id, i) * 0.15)); const edges = (state.adjacency.get(node.id) || []).filter(edge => { const otherID = edge.source === node.id ? edge.target : edge.source; return state.nodeById.get(otherID)?.clusterKey === cluster.key; }); if (edges.length) { const edge = edges[(state.ambientCursor + i) % edges.length]; state.edgeActive.set(edge.id, Math.max(state.edgeActive.get(edge.id) || 0, 0.18)); state.particles.push({edgeId: edge.id, t: -0.03 * i, speed: 0.18 + pseudo(edge.id, i) * 0.22, color: clusterFill(cluster, 0.65), size: 0.62}); } } } function eventMode(evt) { if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) return 'living'; if (!evt || evt.type === 'brain.idle') return 'living'; if (evt.type?.includes('research')) return state.runtimeSettings.thinking_enabled ? 'researching' : 'living'; if (evt.type?.includes('think')) return state.runtimeSettings.thinking_enabled ? 'thinking' : 'living'; if (evt.source === 'agent' || evt.source === 'knowledgebase' || evt.type?.includes('query')) return 'processing'; if (evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type === 'embedding.batch' || evt.type?.startsWith('glpi.kb')) return state.runtimeSettings.learning_enabled ? 'learning' : 'living'; return 'processing'; } function modeParticleColor(mode) { return rgba((MODE_CONFIG[mode] || MODE_CONFIG.living).color, 0.92); } function projectAnimatedNode(node, now) { const honeycomb = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX); const cluster = honeycomb ? null : node.cluster; let x = honeycomb ? node.honeyX : node.x; let y = honeycomb ? node.honeyY : node.y; let z = honeycomb ? node.honeyZ : node.z; if (node.transitionFrom && node.transitionStart) { const t = Math.max(0, Math.min(1, (now - node.transitionStart) / 520)); const eased = t * t * (3 - 2 * t); x = node.transitionFrom.x + (x - node.transitionFrom.x) * eased; y = node.transitionFrom.y + (y - node.transitionFrom.y) * eased; z = node.transitionFrom.z + (z - node.transitionFrom.z) * eased; if (t >= 1) { node.transitionFrom = null; node.transitionStart = 0; } } if (cluster) { const clusterEnergy = state.clusterActive.get(cluster.key) || 0; const learning = state.mode === 'learning'; const ambient = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * (learning ? 0.0025 : 0.012); const expansion = ambient + clusterEnergy * (learning ? 0.006 : 0.055) + (cluster.key === state.focusClusterKey && !learning ? state.activityEnergy * 0.028 : 0); x = cluster.x + (x - cluster.x) * expansion; y = cluster.y + (y - cluster.y) * expansion; z = cluster.z + (z - cluster.z) * expansion; } const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw); const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch); const x1 = x * cy - z * sy; const z1 = x * sy + z * cy; const y1 = y * cp - z1 * sp; const z2 = y * sp + z1 * cp; const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0; const centerX = state.width / 2 + panelOffset; const centerY = state.height / 2 - 4; const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40)); const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom; const perspective = 2.9 / (3.3 - z2 * 0.42); return {x: centerX + x1 * scale * perspective, y: centerY + y1 * scale * perspective, z: z2, p: perspective}; } function drawBackground(now) { const g = ctx.createRadialGradient(state.width * 0.56, state.height * 0.48, 24, state.width * 0.56, state.height * 0.48, Math.max(state.width, state.height) * 0.8); g.addColorStop(0, '#071827'); g.addColorStop(0.52, '#020711'); g.addColorStop(1, '#010207'); ctx.fillStyle = g; ctx.fillRect(0, 0, state.width, state.height); ctx.save(); ctx.globalAlpha = 0.16; for (let i = 0; i < 90; i++) { const x = (i * 193.7 + now * 0.002 * (i % 3 + 1)) % state.width; const y = (i * 97.3) % state.height; ctx.fillStyle = i % 9 === 0 ? '#52e7ff' : '#5d7890'; const size = i % 11 === 0 ? 1.5 : 0.8; ctx.fillRect(x, y, size, size); } ctx.restore(); } function renderActivityAura(now) { const cfg = MODE_CONFIG[state.mode] || MODE_CONFIG.living; const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0; const cx = state.width / 2 + panelOffset; const cy = state.height / 2 - 4; const radius = Math.min(state.height * 0.48, Math.max(280, state.width * 0.34)); const energy = Math.max(0.035, state.activityEnergy); ctx.save(); ctx.globalCompositeOperation = 'screen'; const aura = ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius * 1.18); aura.addColorStop(0, rgba(cfg.color, 0.025 + energy * 0.038)); aura.addColorStop(0.52, rgba(cfg.color, 0.012 + energy * 0.025)); aura.addColorStop(1, rgba(cfg.color, 0)); ctx.fillStyle = aura; ctx.fillRect(cx - radius * 1.25, cy - radius * 1.25, radius * 2.5, radius * 2.5); if (state.mode !== 'living') { const beat = 0.5 + 0.5 * Math.sin(now * 0.0075); ctx.strokeStyle = rgba(cfg.color, 0.035 + energy * 0.07 * beat); ctx.lineWidth = 1 + energy * 1.5; ctx.beginPath(); ctx.arc(cx, cy, radius * (0.58 + beat * 0.025), 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); } function renderClusterClouds(now) { if (state.viewMode === 'honeycomb') return; ctx.save(); ctx.globalCompositeOperation = 'screen'; for (const cluster of state.clusters) { const p = project(cluster); cluster.screen = p; const active = state.clusterActive.get(cluster.key) || 0; const focus = cluster.key === state.focusClusterKey ? state.activityEnergy : 0; const breathe = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * 0.035; const radius = Math.max(44, cluster.radius * 230 * p.p * breathe * (1 + active * 0.08 + focus * 0.1)); const alpha = 0.045 + cluster.importance * 0.026 + active * 0.08 + focus * 0.09; const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius); g.addColorStop(0, clusterFill(cluster, Math.min(0.25, alpha * 1.8))); g.addColorStop(0.32, clusterFill(cluster, Math.min(0.13, alpha))); g.addColorStop(0.78, clusterFill(cluster, 0.012 + focus * 0.025)); g.addColorStop(1, clusterFill(cluster, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * (0.68 + Math.max(-0.12, Math.min(0.12, p.z * 0.16))), p.z * 0.18, 0, Math.PI * 2); ctx.fill(); if (state.cortexVisible && (cluster.importance > 0.62 || active > 0.15 || focus > 0.1)) { ctx.setLineDash([3, 7]); ctx.strokeStyle = clusterFill(cluster, 0.055 + active * 0.13 + focus * 0.16); ctx.lineWidth = 0.65 + focus * 1.1; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, p.z * 0.18, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } } ctx.restore(); } function renderCortexLabels() { if (state.viewMode === 'honeycomb') return; if (!state.cortexVisible || state.width < 850) return; const visible = state.clusters .filter(cluster => cluster.screen && (cluster.importance > 0.52 || cluster.key === state.focusClusterKey)) .sort((a, b) => (b.key === state.focusClusterKey) - (a.key === state.focusClusterKey) || b.mass - a.mass) .slice(0, state.width > 1450 ? 12 : 8); const occupied = []; ctx.save(); ctx.font = '600 10px Inter, system-ui'; ctx.textBaseline = 'middle'; for (const cluster of visible) { const p = cluster.screen; const label = cluster.label.length > 28 ? cluster.label.slice(0, 26) + '…' : cluster.label; const count = cluster.size.toLocaleString('de-DE'); const text = `${label} · ${count}`; const w = ctx.measureText(text).width + 16; let x = p.x + (cluster.side === 'left' ? -w - 16 : 16); let y = p.y - 8; x = Math.max(372, Math.min(state.width - w - 24, x)); y = Math.max(100, Math.min(state.height - 78, y)); if (occupied.some(box => Math.abs(box.x - x) < (box.w + w) * 0.48 && Math.abs(box.y - y) < 22)) continue; occupied.push({x, y, w}); const focus = cluster.key === state.focusClusterKey; ctx.strokeStyle = clusterFill(cluster, focus ? 0.75 : 0.28); ctx.lineWidth = focus ? 1.25 : 0.6; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(cluster.side === 'left' ? x + w : x, y + 8); ctx.stroke(); ctx.fillStyle = focus ? 'rgba(3,10,18,.94)' : 'rgba(3,10,18,.78)'; ctx.fillRect(x, y, w, 17); ctx.strokeStyle = clusterFill(cluster, focus ? 0.65 : 0.18); ctx.strokeRect(x, y, w, 17); ctx.fillStyle = clusterFill(cluster, focus ? 1 : 0.82); ctx.fillText(text, x + 8, y + 8.5); } ctx.restore(); } function renderEdges() { if (state.viewMode === 'honeycomb') return; if (!state.edgesVisible) return; ctx.save(); ctx.globalCompositeOperation = 'screen'; const edges = [...state.renderIdleEdges]; const included = new Set(edges.map(edge => edge.id)); for (const id of state.renderEdgeActive.keys()) { const edge = state.renderEdgeById.get(id); if (edge && !included.has(id)) { edges.push(edge); included.add(id); } } for (const edge of edges) { const a = state.renderNodeById.get(edge.source), b = state.renderNodeById.get(edge.target); if (!a?.screen || !b?.screen) continue; const active = state.renderEdgeActive.get(edge.id) || 0; const density = Math.min(1, Math.log1p(edge.edgeCount || 1) / 5.5); const base = active > 0.01 ? 0.08 + active * 0.7 : edge.origin === 'ai-inference' ? 0.026 : edge.type === 'categorized_as' ? 0.017 : 0.006 + density * 0.014; const alpha = Math.min(0.88, base); if (alpha < 0.01) continue; const mx = (a.screen.x + b.screen.x) / 2; const my = (a.screen.y + b.screen.y) / 2 - Math.abs(a.screen.x - b.screen.x) * 0.035; ctx.beginPath(); ctx.moveTo(a.screen.x, a.screen.y); ctx.quadraticCurveTo(mx, my, b.screen.x, b.screen.y); ctx.strokeStyle = edge.origin === 'ai-inference' ? `rgba(255,180,82,${alpha})` : `rgba(82,181,255,${alpha})`; ctx.lineWidth = 0.25 + active * 1.7 + (edge.confidence || 0) * 0.18 + density * 0.65; ctx.stroke(); } ctx.restore(); } function renderParticles(dt) { if (state.viewMode === 'honeycomb') return; ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let i = state.particles.length - 1; i >= 0; i--) { const particle = state.particles[i]; particle.t += dt * particle.speed; const original = state.edgeById.get(particle.edgeId); const renderEdgeID = state.edgeRenderMap.get(particle.edgeId); const renderEdge = renderEdgeID ? state.renderEdgeById.get(renderEdgeID) : null; let a = renderEdge && state.renderNodeById.get(renderEdge.source); let b = renderEdge && state.renderNodeById.get(renderEdge.target); if (!renderEdge && original) { const visibleID = state.visibleForNode.get(original.source); const sameID = state.visibleForNode.get(original.target); if (visibleID && visibleID === sameID) { a = state.renderNodeById.get(visibleID); b = a; } } if (!original || !a?.screen || !b?.screen || particle.t > 1.08) { state.particles.splice(i, 1); continue; } const q = Math.min(1, particle.t); const ease = q * q * (3 - 2 * q); let x, y; if (a === b) { const angle = q * Math.PI * 2 + hashString(particle.edgeId) * 0.00001; const orbit = 8 + Math.min(18, Math.log1p(a.memberCount || 1) * 3); x = a.screen.x + Math.cos(angle) * orbit; y = a.screen.y + Math.sin(angle) * orbit * 0.55; } else { x = a.screen.x + (b.screen.x - a.screen.x) * ease; y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18; } const size = 8 * (particle.size || 1) * (0.82 + state.activityEnergy * 0.28); const grad = ctx.createRadialGradient(x, y, 0, x, y, size); grad.addColorStop(0, 'rgba(255,255,255,.95)'); grad.addColorStop(0.22, particle.color); grad.addColorStop(1, 'rgba(82,231,255,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function renderNodes(now, dt) { for (const [id, value] of state.active) { const next = Math.max(0, value - dt * 0.44); if (next > 0) state.active.set(id, next); else state.active.delete(id); } for (const [id, value] of state.renderActive) { const next = Math.max(0, value - dt * 0.44); if (next > 0) state.renderActive.set(id, next); else state.renderActive.delete(id); } state.projected = []; for (const node of state.renderNodes) { node.screen = projectAnimatedNode(node, now); state.projected.push(node); } state.projected.sort((a, b) => a.screen.z - b.screen.z); ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const node of state.projected) { const honeycomb = state.viewMode === 'honeycomb'; const isGroup = node.kind === 'supernode'; const act = state.renderActive.get(node.id) || (isGroup ? 0 : state.active.get(node.id) || 0); const hover = state.hover?.id === node.id; const selected = state.selected?.id === node.id; const degree = honeycomb ? 0 : Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length); const memberScale = isGroup ? Math.min(7.5, 1.1 + Math.log2((node.memberCount || 1) + 1) * 0.72) : 0; const baseWeight = honeycomb ? 0.72 : isGroup ? memberScale : Math.max(0.25, Math.min(2.5, (node.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05)); const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + node.x * 7 + node.y * 9); const clusterEnergy = honeycomb ? 0 : state.clusterActive.get(node.clusterKey) || 0; const focused = honeycomb ? 0 : node.clusterKey === state.focusClusterKey ? state.activityEnergy : 0; const r = (isGroup ? 2.4 + baseWeight : 1.05 + baseWeight * 0.7 + node.screen.p * 0.55) * (1 + act * 0.48 + clusterEnergy * 0.06 + focused * 0.025) + (hover || selected ? 1.6 : 0); const idleDim = state.mode === 'living' ? 0 : 0.025; const baseAlpha = honeycomb ? 0.23 : (isGroup ? 0.42 : 0.17); const alpha = Math.min(1, baseAlpha - idleDim + node.screen.p * (honeycomb ? 0.035 : 0.1) + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0)); if (act > 0.05 || hover || selected || (!honeycomb && (node.kind === 'ai-think' || isGroup))) { const haloRadius = r * (isGroup ? 2.35 + act * 2.4 : 3 + act * 4); const halo = ctx.createRadialGradient(node.screen.x, node.screen.y, 0, node.screen.x, node.screen.y, haloRadius); halo.addColorStop(0, nodeColor(node, (isGroup ? 0.28 : 0.48) + act * 0.35)); halo.addColorStop(0.24, nodeColor(node, (isGroup ? 0.09 : 0.14) + act * 0.2)); halo.addColorStop(1, nodeColor(node, 0)); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(node.screen.x, node.screen.y, haloRadius, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = nodeColor(node, alpha); ctx.beginPath(); ctx.arc(node.screen.x, node.screen.y, r * (0.84 + breathe * 0.1), 0, Math.PI * 2); ctx.fill(); if (isGroup) { ctx.strokeStyle = nodeColor(node, 0.4 + act * 0.3); ctx.lineWidth = 0.7 + Math.min(1.4, Math.log1p(node.internalEdgeCount || 0) * 0.12); ctx.beginPath(); ctx.arc(node.screen.x, node.screen.y, r * 1.28 + breathe * 1.3, 0, Math.PI * 2); ctx.stroke(); if (node.memberCount >= 8 && r > 5) { ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.font = '600 8px Inter, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(235,248,255,.82)'; ctx.fillText(node.memberCount > 999 ? `${Math.round(node.memberCount / 100) / 10}k` : String(node.memberCount), node.screen.x, node.screen.y + 0.5); ctx.restore(); } } else if (!honeycomb && node.kind === 'ai-think') { ctx.strokeStyle = nodeColor(node, 0.55); ctx.lineWidth = 0.7; ctx.beginPath(); ctx.arc(node.screen.x, node.screen.y, r * 2 + breathe * 1.8, 0, Math.PI * 2); ctx.stroke(); } } ctx.restore(); if (state.labels) { ctx.save(); ctx.font = '10px Inter, system-ui'; ctx.textBaseline = 'middle'; for (const node of state.projected) { const act = state.renderActive.get(node.id) || (node.kind === 'supernode' ? 0 : state.active.get(node.id) || 0); const isGroup = node.kind === 'supernode'; if (!(act > 0.45 || state.hover?.id === node.id || state.selected?.id === node.id || (state.viewMode !== 'honeycomb' && !isGroup && node.kind === 'ai-think' && node.screen.p > 1.02))) continue; const raw = isGroup ? `${node.label} · ${node.memberCount}` : node.label; const label = raw.length > 42 ? raw.slice(0, 40) + '…' : raw; const w = ctx.measureText(label).width + 12; ctx.fillStyle = 'rgba(2,7,14,.84)'; ctx.fillRect(node.screen.x + 8, node.screen.y - 8, w, 16); ctx.fillStyle = nodeColor(node, 0.95); ctx.fillText(label, node.screen.x + 14, node.screen.y); } ctx.restore(); } } function renderWaves(dt) { ctx.save(); ctx.globalCompositeOperation = 'screen'; for (let i = state.waves.length - 1; i >= 0; i--) { const w = state.waves[i]; w.life -= dt * (w.decay || 1); w.r += dt * (w.speed || 130); if (w.life <= 0) { state.waves.splice(i, 1); continue; } const color = w.color || MODE_CONFIG.living.color; ctx.strokeStyle = rgba(color, w.life * (w.alpha || 0.22)); ctx.lineWidth = (w.width || 1.3) * (0.8 + state.activityEnergy * 0.45); if (w.dashed) ctx.setLineDash([4, 8]); ctx.beginPath(); ctx.arc(w.x, w.y, w.r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } for (let i = state.bursts.length - 1; i >= 0; i--) { const burst = state.bursts[i]; burst.life -= dt * 0.72; if (burst.life <= 0) { state.bursts.splice(i, 1); continue; } const rayCount = burst.rays || 16; const length = (1 - burst.life) * (burst.length || 115) + 18; ctx.strokeStyle = rgba(burst.color, burst.life * 0.18); ctx.lineWidth = 0.7; for (let ray = 0; ray < rayCount; ray++) { const angle = ray / rayCount * Math.PI * 2 + burst.spin; const inner = 9 + (1 - burst.life) * 13; ctx.beginPath(); ctx.moveTo(burst.x + Math.cos(angle) * inner, burst.y + Math.sin(angle) * inner); ctx.lineTo(burst.x + Math.cos(angle) * length, burst.y + Math.sin(angle) * length); ctx.stroke(); } burst.spin += dt * 0.35; } ctx.restore(); } function frame(now) { const dt = Math.min(0.05, (now - state.last) / 1000); state.last = now; updateVisualState(now, dt); if (state.viewMode === 'neural') updateLOD(now); drawBackground(now); if (state.viewMode === 'neural') { renderActivityAura(now); renderClusterClouds(now); renderEdges(); renderParticles(dt); } renderNodes(now, dt); if (state.viewMode === 'neural') { renderWaves(dt); renderCortexLabels(); } else { state.waves.length = 0; state.bursts.length = 0; } for (const [id, value] of state.edgeActive) { const next = value - dt * (state.mode === 'living' ? 0.34 : 0.5); if (next <= 0) state.edgeActive.delete(id); else state.edgeActive.set(id, next); } for (const [id, value] of state.renderEdgeActive) { const next = value - dt * (state.mode === 'living' ? 0.34 : 0.5); if (next <= 0) state.renderEdgeActive.delete(id); else state.renderEdgeActive.set(id, next); } requestAnimationFrame(frame); } requestAnimationFrame(frame); function activate(evt) { const strength = Math.max(0.15, Math.min(1.4, evt.strength || 0.6)); const mode = eventMode(evt); const substep = evt.type === 'node.activated' || evt.type === 'edges.traversed'; if (evt.type !== 'brain.idle' && !substep) setVisualMode(mode, evt, strength); if (state.viewMode === 'neural' && evt.type !== 'brain.idle' && mode !== 'learning' && evt.node_ids?.length) { const duration = mode === 'thinking' || mode === 'researching' ? 45000 : LOD_CONFIG.revealNodeMS; revealLODNodes(evt.node_ids, duration); rebuildRenderGraph(performance.now(), !substep); } if (substep && !state.focusClusterKey && evt.node_ids?.length) { const focus = state.nodeById.get(evt.node_ids[0]); if (focus) focusCamera(focus); } const modeColor = (MODE_CONFIG[mode] || MODE_CONFIG.living).color; for (const id of evt.node_ids || []) { const node = state.nodeById.get(id); state.active.set(id, Math.max(state.active.get(id) || 0, strength)); const visibleID = state.visibleForNode.get(id); if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, strength)); if (node?.clusterKey) state.clusterActive.set(node.clusterKey, Math.max(state.clusterActive.get(node.clusterKey) || 0, strength)); } if (state.viewMode === 'neural') { for (const id of evt.edge_ids || []) { state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength)); const renderID = state.edgeRenderMap.get(id); if (renderID) state.renderEdgeActive.set(renderID, Math.max(state.renderEdgeActive.get(renderID) || 0, strength)); const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5)); for (let i = 0; i < count; i++) { state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86}); } } } if (evt.type !== 'brain.idle') { state.pulses++; $('pulseCount').textContent = state.pulses.toLocaleString('de-DE'); if (state.viewMode === 'neural') { const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null; const visibleID = focus ? state.visibleForNode.get(focus.id) : ''; const visibleFocus = visibleID ? state.renderNodeById.get(visibleID) : null; const x = visibleFocus?.screen?.x ?? focus?.screen?.x ?? state.width / 2; const y = visibleFocus?.screen?.y ?? focus?.screen?.y ?? state.height / 2; const waveCount = mode === 'thinking' ? 3 : mode === 'researching' ? 2 : 1; for (let i = 0; i < waveCount; i++) { state.waves.push({x, y, r: 18 + i * 17, life: 1 - i * 0.08, color: modeColor, speed: 118 + i * 35, width: mode === 'thinking' ? 1.8 : 1.25, alpha: mode === 'thinking' ? 0.3 : 0.24, dashed: mode === 'researching' && i === 1}); } if (mode === 'thinking' || mode === 'researching') state.bursts.push({x, y, life: 1, color: modeColor, rays: mode === 'thinking' ? 22 : 16, length: mode === 'thinking' ? 150 : 115, spin: pseudo(evt.id || evt.type, 9) * Math.PI}); } } addLog(evt); if (evt.type === 'graph.updated') loadGraph(); if (evt.type?.startsWith('think.')) loadStatus(); } function shouldLog(evt) { if (!evt || evt.type === 'brain.idle' || evt.type === 'node.activated' || evt.type === 'edges.traversed') return false; const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed']); if (!important.has(evt.type) && !(evt.source === 'agent' || evt.source === 'knowledgebase' || evt.source === 'external' || evt.query)) return false; const fingerprint = `${evt.type}|${evt.message || ''}|${evt.query || ''}|${evt.source || ''}`; const last = state.lastLogFingerprint.get(fingerprint) || 0; const now = Date.now(); const cooldown = evt.type === 'embedding.batch' ? 45000 : evt.type === 'graph.updated' ? 15000 : 4000; if (now - last < cooldown) return false; state.lastLogFingerprint.set(fingerprint, now); return true; } function formatEvent(evt) { const time = new Date(evt.timestamp || Date.now()).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'}); const nodeObjects = (evt.node_ids || []).map(id => state.nodeById.get(id)).filter(Boolean); const nodes = nodeObjects.map(node => node.label); const regions = [...new Set(nodeObjects.map(node => node.clusterKey).filter(Boolean))].slice(0, 3); const titleMap = { 'scan.started': 'Synchronisation', 'graph.updated': 'Graph aktualisiert', 'embedding.batch': 'Embeddings', 'query.started': evt.source === 'knowledgebase' ? 'Knowledgebase-Suche' : evt.source === 'agent' ? 'Agent-Suche' : 'Suchanfrage', 'query.completed': 'Suche abgeschlossen', 'think.queued': 'AI-THINK eingeplant', 'think.cycle.started': 'Autonomer AI-THINK-Zyklus', 'think.cycle.completed': 'AI-THINK-Zyklus beendet', 'think.cycle.failed': 'AI-THINK-Zyklus fehlgeschlagen', 'think.no_candidate': 'Keine neue Beziehung', 'think.started': 'AI-THINK prüft Zusammenhang', 'think.created': 'AI-THINK erstellt Beitrag', 'think.rejected': 'AI-THINK verwirft Edge', 'think.failed': 'AI-THINK Fehler', 'think.paused': 'AI-THINK pausiert', 'research.started': 'Recherche gestartet', 'agent.run': 'Agent-Lauf', 'glpi.kb.synced': 'GLPI-KB synchronisiert', 'glpi.kb.failed': 'GLPI-KB Fehler', 'persistence.flushed': 'Gebündelt gespeichert', 'persistence.failed': 'Speicherfehler' }; const title = titleMap[evt.type] || evt.phase || evt.source || 'Aktivität'; const meta = []; if (evt.metadata?.nodes) meta.push(`${Number(evt.metadata.nodes).toLocaleString('de-DE')} Nodes`); if (evt.metadata?.edges) meta.push(`${Number(evt.metadata.edges).toLocaleString('de-DE')} Edges`); if (evt.metadata?.documents) meta.push(`${Number(evt.metadata.documents).toLocaleString('de-DE')} GLPI-Beiträge`); if (evt.metadata?.files !== undefined) meta.push(`${Number(evt.metadata.files).toLocaleString('de-DE')} Dateien`); if (evt.metadata?.graph_version !== undefined) meta.push(`Graph v${Number(evt.metadata.graph_version).toLocaleString('de-DE')}`); if (evt.metadata?.duration_ms) meta.push(`${Number(evt.metadata.duration_ms).toLocaleString('de-DE')} ms`); if (evt.metadata?.hit_count) meta.push(`${Number(evt.metadata.hit_count).toLocaleString('de-DE')} Treffer`); if (evt.metadata?.used_nodes) meta.push(`${Number(evt.metadata.used_nodes).toLocaleString('de-DE')} Quellen`); if (evt.metadata?.batch_count) meta.push(`Batch ${Number(evt.metadata.batch_count).toLocaleString('de-DE')}`); if (evt.metadata?.semantic_similarity !== undefined) meta.push(`${Math.round(Number(evt.metadata.semantic_similarity) * 100)}% Nähe`); if (evt.metadata?.confidence !== undefined) meta.push(`${Math.round(Number(evt.metadata.confidence) * 100)}% Konfidenz`); if (evt.metadata?.relation_type) meta.push(String(evt.metadata.relation_type)); if (evt.metadata?.research_result_count) meta.push(`${Number(evt.metadata.research_result_count)} Webquellen`); if (evt.metadata?.trigger) meta.push(evt.metadata.trigger === 'manual' ? 'manuell' : 'automatisch'); if (evt.metadata?.batch_size) meta.push(`${Number(evt.metadata.batch_size)} Schritte`); if (evt.metadata?.checked !== undefined) meta.push(`${Number(evt.metadata.checked)} geprüft`); if (evt.metadata?.created !== undefined) meta.push(`${Number(evt.metadata.created)} erstellt`); if (evt.metadata?.rejected !== undefined) meta.push(`${Number(evt.metadata.rejected)} verworfen`); if (evt.metadata?.comparisons) meta.push(`${Number(evt.metadata.comparisons).toLocaleString('de-DE')} Vergleiche`); if (evt.metadata?.candidate_comparisons) meta.push(`${Number(evt.metadata.candidate_comparisons).toLocaleString('de-DE')} Vergleiche`); if (evt.metadata?.threshold !== undefined) meta.push(`Schwelle ${Math.round(Number(evt.metadata.threshold) * 100)}%`); if ((evt.node_ids || []).length) meta.push(`${evt.node_ids.length} aktive Knoten`); if ((evt.edge_ids || []).length) meta.push(`${evt.edge_ids.length} aktive Kanten`); if (evt.metadata?.ticket_id) meta.push(`Ticket ${evt.metadata.ticket_id}`); if (evt.metadata?.outcome) meta.push(String(evt.metadata.outcome)); if (evt.metadata?.path) meta.push(String(evt.metadata.path).split('/').slice(-2).join('/')); const eventQuery = evt.query || evt.metadata?.research_query || ''; if (eventQuery) meta.push('Query'); let message = evt.message || eventQuery || evt.type; if ((evt.type === 'think.started' || evt.type === 'think.created' || evt.type === 'think.rejected' || evt.type === 'research.started') && nodes.length >= 2) { message = `${message} · ${nodes.slice(0, 2).join(' ↔ ')}`; } else if (evt.type === 'query.started' && evt.query) { message = `${evt.source === 'agent' ? 'Agent' : evt.source === 'knowledgebase' ? 'Knowledgebase' : 'Brain'} verarbeitet eine Anfrage.`; } return {time, title, message, meta, query: eventQuery, regions, cls: evt.type?.includes('think') ? 'think' : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type?.startsWith('glpi.kb') || evt.type?.startsWith('persistence.') ? 'graph' : evt.source === 'agent' ? 'agent' : ''}; } function addLog(evt) { if (!shouldLog(evt)) return; const out = formatEvent(evt); const item = document.createElement('div'); item.className = 'activity-item ' + out.cls; item.innerHTML = `${escapeHTML(out.title)}
${escapeHTML(out.message)}
${out.regions.length ? `