Files
glpi-neural-brain/internal/web/static/app.js
2026-08-07 21:54:37 +02:00

3363 lines
169 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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 TRANSITION_CONFIG = {
enterMS: 720,
exitMS: 680,
moveMS: 620,
viewMS: 760
};
const PERFORMANCE_CONFIG = {
normal: {dpr: 2, minFrameMS: 0, idleEdges: 4200, particles: 900, clouds: 40, stars: 90, researchSources: 7},
eco: {dpr: 1.15, minFrameMS: 33, idleEdges: 850, particles: 220, clouds: 14, stars: 28, researchSources: 4}
};
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, fullNodeById: new Map(), runtimeSettings: {source_filter_version: 1, learning_enabled: true, thinking_enabled: true, learning_sources: [], display_sources: [], thinking_sources: [], glpi_kb_source: '', view_mode: 'neural', max_display_nodes: 0, low_power_mode: false, processing_mode: 'precise', autonomous_research_enabled: false, autonomous_research_idle_only: true, autonomous_research_min_priority: 0.65, autonomous_research_max_tasks_per_day: 12, autonomous_research_tasks_per_cycle: 1},
availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
constellationNodes: [], constellationLinks: [], settingsOpen: false, settingsDraft: null,
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
researchAnimations: new Map(), researchSequence: 0,
lowPowerMode: false, performanceProfile: PERFORMANCE_CONFIG.normal, lastPaint: 0, fpsWindowStarted: performance.now(), fpsFrames: 0, fps: 0,
backgroundCanvas: document.createElement('canvas'), backgroundKey: '', cameraFrame: null, projectedSortAt: 0,
retiringRenderNodes: [], retiringRenderEdges: [], retiringRenderNodeById: new Map(), viewGhostNodes: [],
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0, sourceOptionsTimer: 0, autonomousTasks: [], autonomousTaskStatus: {}, autonomousTasksTimer: 0
};
function currentPerformanceProfile() {
return state.lowPowerMode ? PERFORMANCE_CONFIG.eco : PERFORMANCE_CONFIG.normal;
}
function rebuildBackgroundCache() {
const bg = state.backgroundCanvas;
const profile = currentPerformanceProfile();
const key = `${state.width}:${state.height}:${profile.stars}`;
if (state.backgroundKey === key && bg.width && bg.height) return;
state.backgroundKey = key;
bg.width = Math.max(1, Math.floor(state.width));
bg.height = Math.max(1, Math.floor(state.height));
const bctx = bg.getContext('2d', {alpha: false});
const g = bctx.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');
bctx.fillStyle = g;
bctx.fillRect(0, 0, state.width, state.height);
bctx.globalAlpha = state.lowPowerMode ? 0.12 : 0.16;
for (let i = 0; i < profile.stars; i++) {
const x = pseudo(`background:${i}`, 1) * state.width;
const y = pseudo(`background:${i}`, 2) * state.height;
bctx.fillStyle = i % 9 === 0 ? '#52e7ff' : '#5d7890';
const size = i % 11 === 0 ? 1.5 : 0.8;
bctx.fillRect(x, y, size, size);
}
bctx.globalAlpha = 1;
}
function applyPerformanceMode(enabled, resizeCanvas = true) {
state.lowPowerMode = Boolean(enabled);
state.runtimeSettings.low_power_mode = state.lowPowerMode;
state.performanceProfile = currentPerformanceProfile();
document.body.classList.toggle('low-power', state.lowPowerMode);
const eco = $('toggleEco');
if (eco) {
eco.classList.toggle('active', state.lowPowerMode);
eco.title = state.lowPowerMode ? 'Eco-Modus aktiv · 30 FPS und reduzierte Effektkosten' : 'Optimierter Renderpfad für schwächere Systeme';
}
if ($('settingsLowPower')) $('settingsLowPower').checked = Boolean((state.settingsOpen && state.settingsDraft ? state.settingsDraft : state.runtimeSettings).low_power_mode);
state.backgroundKey = '';
state.projectedSortAt = 0;
if (state.particles.length > state.performanceProfile.particles) {
state.particles.splice(0, state.particles.length - state.performanceProfile.particles);
}
if (state.clusters.length) buildConstellationLinks();
if (state.viewMode === 'neural') state.lodDirty = true;
if (resizeCanvas) resize();
}
function resize() {
state.width = innerWidth;
state.height = innerHeight;
const profile = currentPerformanceProfile();
state.dpr = Math.min(devicePixelRatio || 1, profile.dpr);
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);
state.backgroundKey = '';
rebuildBackgroundCache();
}
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) {
const error = new Error(data.error || `HTTP ${res.status}`);
error.status = res.status;
error.data = data;
throw error;
}
return data;
}
function normalizedSourceValues(values) {
return [...new Set((values || []).map(value => String(value).trim()).filter(Boolean))];
}
// Source filtering intentionally uses only the explicit metadata.source value
// copied from a KB file (or assigned to learned web evidence). No origin,
// category, URI or display-label fallback is used.
function nodeSource(node) {
const explicit = node?.metadata?.source;
if (explicit === undefined || explicit === null) return '';
const value = String(explicit).trim();
if (!value || value.toLowerCase() === '<nil>' || value.toLowerCase() === 'null') return '';
return value;
}
function sourceFilterMatches(node, sources) {
const wanted = normalizedSourceValues(sources);
if (!wanted.length) return true;
const source = nodeSource(node);
return wanted.some(value => source === value);
}
function runtimeSources(key, settings = state.runtimeSettings) {
return normalizedSourceValues(settings?.[`${key}_sources`] || []);
}
function activeForcedDisplayIDs(now = Date.now()) {
const active = new Set();
let next = 0;
for (const [id, until] of state.forcedDisplayUntil) {
if (until <= now) {
state.forcedDisplayUntil.delete(id);
continue;
}
active.add(id);
if (!next || until < next) next = until;
}
state.nextDisplayLimitExpiry = next;
return active;
}
function nodeLimitScore(node, degree, forced) {
if (forced.has(node.id)) return 1e12 + degree;
let score = Math.min(5000, degree * 8) + Math.min(100, Number(node.weight || 1) * 4);
if (node.kind === 'knowledge') score += 120;
if (node.kind === 'ai-think') score += 230;
if (node.status === 'staging') score += 180;
if (node.kind === 'external') score += 105;
if (node.kind === 'category') score += 45;
if (node.kind === 'source') score += 30;
if (state.active.has(node.id)) score += 500000;
if (state.selected?.id === node.id || state.hover?.id === node.id) score += 750000;
return score + pseudo(node.id, 71) * 5;
}
function limitSnapshot(snapshot, rawLimit) {
const limit = Math.max(0, Math.min(500000, Math.trunc(Number(rawLimit) || 0)));
const eligible = snapshot.nodes.length;
if (!limit || eligible <= limit) {
state.displayLimitStats = {limit, eligible, shown: eligible};
return snapshot;
}
const forced = activeForcedDisplayIDs();
const degree = new Map(snapshot.nodes.map(node => [node.id, 0]));
for (const edge of snapshot.edges) {
degree.set(edge.source, (degree.get(edge.source) || 0) + 1);
degree.set(edge.target, (degree.get(edge.target) || 0) + 1);
}
const selected = new Set();
const buckets = new Map();
const candidates = snapshot.nodes.map(node => ({node, score: nodeLimitScore(node, degree.get(node.id) || 0, forced)}));
candidates.sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id));
for (const candidate of candidates) {
if (!forced.has(candidate.node.id) || selected.size >= limit) continue;
selected.add(candidate.node.id);
}
for (const candidate of candidates) {
if (selected.has(candidate.node.id)) continue;
const key = categoryKey(candidate.node) || 'Sonstige';
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(candidate);
}
const bucketList = [...buckets.values()].sort((a, b) => (b[0]?.score || 0) - (a[0]?.score || 0));
for (const bucket of bucketList) {
if (selected.size >= limit || !bucket.length) break;
selected.add(bucket.shift().node.id);
}
const remainder = [];
for (const bucket of bucketList) remainder.push(...bucket);
remainder.sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id));
for (const candidate of remainder) {
if (selected.size >= limit) break;
selected.add(candidate.node.id);
}
const nodes = snapshot.nodes.filter(node => selected.has(node.id));
const edges = snapshot.edges.filter(edge => selected.has(edge.source) && selected.has(edge.target));
state.displayLimitStats = {limit, eligible, shown: nodes.length};
return {...snapshot, nodes, edges};
}
function filteredSnapshot(snapshot) {
const sources = runtimeSources('display');
let filtered = snapshot;
if (sources.length) {
const visible = new Set();
const noteKinds = new Set(['knowledge', 'ai-think', 'external']);
const taxonomyKinds = new Set(['category', 'source', 'concept']);
const nodeMap = new Map(snapshot.nodes.map(node => [node.id, node]));
for (const node of snapshot.nodes) {
if (noteKinds.has(node.kind) && sourceFilterMatches(node, sources)) visible.add(node.id);
}
// Taxonomy is visual context only. Another knowledge-bearing node is
// never pulled through an edge when its exact source does not match.
for (const edge of snapshot.edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (visible.has(edge.source) && target && taxonomyKinds.has(target.kind)) visible.add(edge.target);
if (visible.has(edge.target) && source && taxonomyKinds.has(source.kind)) visible.add(edge.source);
}
filtered = {
...snapshot,
nodes: snapshot.nodes.filter(node => visible.has(node.id)),
edges: snapshot.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target))
};
}
return limitSnapshot(filtered, state.runtimeSettings.max_display_nodes);
}
function displaySignatureFor(settings = state.runtimeSettings) {
return {sources: runtimeSources('display', settings), limit: Number(settings.max_display_nodes || 0)};
}
function currentDisplaySignature() {
const forced = [...activeForcedDisplayIDs()].sort();
return JSON.stringify({...displaySignatureFor(), forced});
}
async function loadSourceOptions() {
const payload = await api('/api/sources');
state.availableSources = payload.sources || [];
renderSourceFilters($('sourceSearch')?.value || '');
return state.availableSources;
}
function scheduleSourceOptionsLoad(delay = 260) {
clearTimeout(state.sourceOptionsTimer);
state.sourceOptionsTimer = setTimeout(() => {
state.sourceOptionsTimer = 0;
loadSourceOptions().catch(() => {});
}, delay);
}
async function loadRuntimeConfiguration() {
try {
const [settings] = await Promise.all([api('/api/runtime-settings'), loadSourceOptions()]);
state.runtimeSettings = {...state.runtimeSettings, ...settings};
state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural';
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
syncRuntimeControls();
renderSourceFilters();
renderFilterScopeSummary();
} 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 === 'neural');
if ($('settingsViewHoneycomb')) $('settingsViewHoneycomb').classList.toggle('active', panelSettings.view_mode === 'honeycomb');
if ($('settingsViewConstellation')) $('settingsViewConstellation').classList.toggle('active', panelSettings.view_mode === 'constellation');
if ($('settingsLowPower')) $('settingsLowPower').checked = Boolean(panelSettings.low_power_mode);
if ($('settingsProcessingPrecise')) $('settingsProcessingPrecise').classList.toggle('active', panelSettings.processing_mode !== 'clustered');
if ($('settingsProcessingClustered')) $('settingsProcessingClustered').classList.toggle('active', panelSettings.processing_mode === 'clustered');
if ($('processingModeHint')) $('processingModeHint').textContent = panelSettings.processing_mode === 'clustered' ? 'Cluster/Fast: Semantic Hashing ersetzt den Vollscan; nur Top-K-Kandidaten erhalten exakte Cosine-Bewertungen. Der Artikelreview nutzt einen kompakten, priorisierten Evidenzsatz.' : 'Präzise: vollständige Cosine-Suche über jeden Anchor und den gesamten gefilterten Wissensraum.';
if ($('settingsMaxDisplayNodes')) $('settingsMaxDisplayNodes').value = String(Math.max(0, Number(panelSettings.max_display_nodes || 0)));
if ($('settingsAutonomousResearch')) $('settingsAutonomousResearch').checked = Boolean(panelSettings.autonomous_research_enabled);
if ($('settingsAutonomousIdleOnly')) $('settingsAutonomousIdleOnly').checked = panelSettings.autonomous_research_idle_only !== false;
if ($('settingsAutonomousMinPriority')) $('settingsAutonomousMinPriority').value = String(Math.max(0, Math.min(1, Number(panelSettings.autonomous_research_min_priority ?? 0.65))));
if ($('settingsAutonomousMaxPerDay')) $('settingsAutonomousMaxPerDay').value = String(Math.max(1, Number(panelSettings.autonomous_research_max_tasks_per_day || 12)));
if ($('settingsAutonomousPerCycle')) $('settingsAutonomousPerCycle').value = String(Math.max(1, Number(panelSettings.autonomous_research_tasks_per_cycle || 1)));
updateDisplayLimitHint(panelSettings.max_display_nodes);
const enrichButton = $('enrichNow');
if (enrichButton && !state.brainStatus?.enrich_running) enrichButton.disabled = !thinking;
if (!learning && !thinking) setVisualMode('living');
updateViewButtons();
}
function updateDisplayLimitHint(value = state.runtimeSettings.max_display_nodes) {
const hint = $('displayLimitHint');
if (!hint) return;
const limit = Math.max(0, Math.trunc(Number(value) || 0));
if (!limit) {
hint.textContent = 'Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.';
return;
}
const stats = state.displayLimitStats || {};
const eligible = Number(stats.eligible || state.fullSnapshot?.nodes?.length || 0);
hint.textContent = `Maximal ${limit.toLocaleString('de-DE')} Nodes · aktuell ${Math.min(limit, eligible).toLocaleString('de-DE')} von ${eligible.toLocaleString('de-DE')} auswählbar.`;
}
function sourceLabel(name) {
return String(name || '');
}
function sourceSet(key) {
const settings = state.settingsDraft || state.runtimeSettings;
return new Set(normalizedSourceValues(settings[`${key}_sources`] || []));
}
function renderSourceFilters(search = '') {
const query = String(search || '').trim().toLowerCase();
const settings = state.settingsDraft || state.runtimeSettings;
const optionMap = new Map((state.availableSources || []).map(option => [String(option.name), {...option}]));
const configuredGLPI = String(state.runtimeSettings.glpi_kb_source || '').trim();
if (configuredGLPI && !optionMap.has(configuredGLPI)) optionMap.set(configuredGLPI, {name: configuredGLPI, count: 0});
for (const key of ['learning', 'display', 'thinking']) {
for (const source of runtimeSources(key, settings)) {
if (!optionMap.has(source)) optionMap.set(source, {name: source, count: 0});
}
}
const options = [...optionMap.values()].filter(option => !query || sourceLabel(option.name).toLowerCase().includes(query));
const targets = {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')};
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = sourceSet(key);
target.innerHTML = '';
for (const option of options) {
const label = document.createElement('label');
label.className = 'source-option';
const checked = selected.has(String(option.name));
const configured = option.name === state.runtimeSettings.glpi_kb_source && Number(option.count || 0) === 0 ? '<small>GLPI_KB_SOURCE</small>' : '';
label.innerHTML = `<input type="checkbox" data-source-filter="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(sourceLabel(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${configured}</span>`;
target.appendChild(label);
}
if (!options.length) target.innerHTML = '<span class="empty-filter">Keine passende source gefunden</span>';
}
}
function compactSourceValues(values) {
if (!values?.length) return 'alle source-Werte';
const labels = values.slice(0, 3).map(sourceLabel);
return labels.join(', ') + (values.length > 3 ? ` +${values.length - 3}` : '');
}
function renderFilterScopeSummary() {
const target = $('filterScopeSummary');
if (!target) return;
const settings = state.settingsDraft || state.runtimeSettings;
const parts = [
`Lernen: ${compactSourceValues(runtimeSources('learning', settings))}`,
`Anzeige: ${compactSourceValues(runtimeSources('display', settings))}`,
`Thinking: ${compactSourceValues(runtimeSources('thinking', settings))}`
];
target.textContent = `Exakter KB-source-Treffer — ${parts.join(' | ')}`;
target.classList.remove('warn');
target.classList.add('ok');
}
async function persistRuntimeSettings(settings = state.runtimeSettings) {
const normalized = {
source_filter_version: 1,
learning_enabled: Boolean(settings.learning_enabled),
thinking_enabled: Boolean(settings.thinking_enabled),
learning_sources: normalizedSourceValues(settings.learning_sources),
display_sources: normalizedSourceValues(settings.display_sources),
thinking_sources: normalizedSourceValues(settings.thinking_sources),
view_mode: ['neural', 'honeycomb', 'constellation'].includes(settings.view_mode) ? settings.view_mode : 'neural',
max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))),
low_power_mode: Boolean(settings.low_power_mode),
processing_mode: settings.processing_mode === 'clustered' ? 'clustered' : 'precise',
autonomous_research_enabled: Boolean(settings.autonomous_research_enabled),
autonomous_research_idle_only: settings.autonomous_research_idle_only !== false,
autonomous_research_min_priority: Math.max(0, Math.min(1, Number(settings.autonomous_research_min_priority ?? 0.65))),
autonomous_research_max_tasks_per_day: Math.max(1, Math.min(500, Math.trunc(Number(settings.autonomous_research_max_tasks_per_day) || 12))),
autonomous_research_tasks_per_cycle: Math.max(1, Math.min(8, Math.trunc(Number(settings.autonomous_research_tasks_per_cycle) || 1)))
};
const previousDisplay = JSON.stringify(displaySignatureFor(state.runtimeSettings));
const updated = await api('/api/runtime-settings', {method: 'PUT', body: JSON.stringify(normalized)});
state.runtimeSettings = {...normalized, ...updated};
const nextViewMode = state.runtimeSettings.view_mode;
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
syncRuntimeControls();
renderFilterScopeSummary();
applyViewMode(nextViewMode, false);
if (previousDisplay !== JSON.stringify(displaySignatureFor(state.runtimeSettings))) {
state.displaySignature = '';
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();
renderSourceFilters($('sourceSearch')?.value || '');
renderFilterScopeSummary();
}
function closeSettingsPanel() {
state.settingsOpen = false;
state.settingsDraft = null;
$('settingsPanel')?.classList.add('hidden');
$('settingsBackdrop')?.classList.add('hidden');
}
function sameIDSet(previous, items) {
if (previous.size !== items.length) return false;
for (const item of items) if (!previous.has(item.id)) return false;
return true;
}
function nodePositionSnapshot(node) {
return {
x: Number(node.x || 0), y: Number(node.y || 0), z: Number(node.z || 0),
neuralX: Number(node.neuralX ?? node.x ?? 0), neuralY: Number(node.neuralY ?? node.y ?? 0), neuralZ: Number(node.neuralZ ?? node.z ?? 0),
honeyX: node.honeyX, honeyY: node.honeyY, honeyZ: node.honeyZ,
constellationX: node.constellationX, constellationY: node.constellationY, constellationZ: node.constellationZ,
clusterKey: node.clusterKey || '', glow: node.glow || 0, clusterColor: node.clusterColor || ''
};
}
function applyGraphSnapshot(snap, force = false) {
const displaySignature = currentDisplaySignature();
if (!force && state.fullSnapshot && state.graphVersion === snap.version && state.displaySignature === displaySignature) return false;
const now = performance.now();
state.graphVersion = snap.version;
state.displaySignature = displaySignature;
state.fullSnapshot = snap;
state.fullNodeById = new Map(snap.nodes.map(node => [node.id, node]));
const filtered = filteredSnapshot(snap);
const old = state.nodeById;
const oldClusters = state.clusterByKey;
const previousPositions = new Map();
for (const [id, node] of old) previousPositions.set(id, nodePositionSnapshot(node));
const nodeTopologyChanged = !sameIDSet(state.topologyNodeIDs, filtered.nodes);
const edgeTopologyChanged = !sameIDSet(state.topologyEdgeIDs, filtered.edges);
const nextNodes = [];
for (const raw of filtered.nodes) {
const existing = old.get(raw.id);
if (existing) {
const preserved = nodePositionSnapshot(existing);
Object.assign(existing, raw);
existing.glow = preserved.glow;
existing.screen = existing.screen || null;
existing.clusterKey = preserved.clusterKey;
existing.clusterColor = preserved.clusterColor;
existing.neuralX = preserved.neuralX;
existing.neuralY = preserved.neuralY;
existing.neuralZ = preserved.neuralZ;
existing.honeyX = preserved.honeyX;
existing.honeyY = preserved.honeyY;
existing.honeyZ = preserved.honeyZ;
existing.constellationX = preserved.constellationX;
existing.constellationY = preserved.constellationY;
existing.constellationZ = preserved.constellationZ;
existing._fadeOutStart = 0;
nextNodes.push(existing);
} else {
nextNodes.push({...raw, glow: 0, screen: null, clusterKey: '', clusterColor: '', _fadeInStart: now});
}
}
state.nodes = nextNodes;
state.edges = filtered.edges;
state.nodeById = new Map(state.nodes.map(node => [node.id, node]));
state.edgeById = new Map(state.edges.map(edge => [edge.id, edge]));
state.adjacency = new Map();
for (const edge of state.edges) {
if (!state.adjacency.has(edge.source)) state.adjacency.set(edge.source, []);
if (!state.adjacency.has(edge.target)) state.adjacency.set(edge.target, []);
state.adjacency.get(edge.source).push(edge);
state.adjacency.get(edge.target).push(edge);
}
if (nodeTopologyChanged || !state.clusters.length) {
buildLayout(previousPositions, oldClusters);
for (const node of state.nodes) {
const previous = previousPositions.get(node.id);
node.neuralX = node.x;
node.neuralY = node.y;
node.neuralZ = node.z;
if (previous && Math.hypot(node.x - previous.neuralX, node.y - previous.neuralY, node.z - previous.neuralZ) > 0.004) {
node.transitionFrom = {x: previous.neuralX, y: previous.neuralY, z: previous.neuralZ};
node.transitionStart = now;
}
}
buildHoneycombLayout();
buildConstellationLayout();
buildLODHierarchy();
} else if (edgeTopologyChanged) {
refreshClusterLinks();
buildConstellationLinks();
state.lodDirty = true;
}
state.topologyNodeIDs = new Set(state.nodes.map(node => node.id));
state.topologyEdgeIDs = new Set(state.edges.map(edge => edge.id));
applyViewMode(state.runtimeSettings.view_mode || state.viewMode, false);
$('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE');
const limit = Number(state.runtimeSettings.max_display_nodes || 0);
const capText = limit ? ` · GPU-Limit ${limit.toLocaleString('de-DE')}` : '';
$('nodeCount').parentElement.title = `${state.nodes.length.toLocaleString('de-DE')} angezeigt · ${snap.nodes.length.toLocaleString('de-DE')} insgesamt${capText}`;
$('edgeCount').textContent = state.edges.length.toLocaleString('de-DE');
updateDisplayLimitHint(limit);
return true;
}
async function loadGraph() {
if (state.graphLoadPromise) {
state.graphLoadQueued = true;
return state.graphLoadPromise;
}
state.graphLoadPromise = (async () => {
try {
const snap = await api('/api/graph');
applyGraphSnapshot(snap, false);
} catch {
setSystem('offline', false);
} finally {
state.graphLoadPromise = null;
if (state.graphLoadQueued) {
state.graphLoadQueued = false;
scheduleGraphLoad(160);
}
}
})();
return state.graphLoadPromise;
}
function scheduleGraphLoad(delay = 120) {
clearTimeout(state.graphLoadTimer);
state.graphLoadTimer = setTimeout(() => {
state.graphLoadTimer = 0;
loadGraph();
}, delay);
}
function forceDisplayNodes(ids, duration = 36000) {
const limit = Number(state.runtimeSettings.max_display_nodes || 0);
if (!limit || !state.fullSnapshot || !ids?.length) return false;
const known = state.fullNodeById;
const now = Date.now();
let changed = false;
for (const id of ids) {
if (!known.has(id)) continue;
const until = now + duration;
if ((state.forcedDisplayUntil.get(id) || 0) < until) state.forcedDisplayUntil.set(id, until);
if (!state.nodeById.has(id)) changed = true;
}
activeForcedDisplayIDs(now);
if (changed) applyGraphSnapshot(state.fullSnapshot, true);
return changed;
}
function expireForcedDisplayNodes() {
if (!state.nextDisplayLimitExpiry || Date.now() < state.nextDisplayLimitExpiry) return;
const before = state.forcedDisplayUntil.size;
activeForcedDisplayIDs();
if (before !== state.forcedDisplayUntil.size && state.fullSnapshot) applyGraphSnapshot(state.fullSnapshot, true);
}
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);
renderResearchStatus(status.searxng || {configured: Boolean(status.research_enabled)});
renderAutonomousResearchStatus(status.autonomous_research || {});
scheduleAutonomousTasksLoad();
} catch {
renderAutonomyStatus({ollama_ok: false, auto_enrich: false, enrich_error: 'Status nicht erreichbar'});
}
}
function renderResearchStatus(status = {}) {
const box = $('researchStatus');
const badge = $('researchStatusBadge');
if (!box || !badge) return;
const configured = Boolean(status.configured);
const checked = Boolean(status.checked_at);
const ok = Boolean(status.ok);
const endpoint = status.base_url || 'SEARXNG_URL nicht gesetzt';
box.className = `research-status ${checked ? (ok ? 'ok' : 'failed') : ''}`;
if (!configured) {
badge.textContent = 'deaktiviert';
box.innerHTML = `<strong>Nicht konfiguriert</strong>${escapeHTML(status.error || 'BRAIN_RESEARCH_ENABLED und SEARXNG_URL prüfen.')}`;
return;
}
if (status.error_kind === 'disabled') {
badge.textContent = 'deaktiviert';
box.innerHTML = `<strong>${escapeHTML(endpoint)}</strong>${escapeHTML(status.error || 'BRAIN_RESEARCH_ENABLED ist deaktiviert.')}`;
return;
}
if (!checked) {
badge.textContent = 'bereit';
box.innerHTML = `<strong>${escapeHTML(endpoint)}</strong>Noch keine echte JSON-Suche ausgeführt.`;
return;
}
if (ok) {
badge.textContent = 'erreichbar';
const count = Number(status.result_count || 0);
box.innerHTML = `<strong>${escapeHTML(endpoint)}</strong>HTTP ${Number(status.http_status || 200)} · ${count} Treffer · ${Number(status.duration_ms || 0).toLocaleString('de-DE')} ms`;
return;
}
badge.textContent = 'Fehler';
const kind = status.error_kind ? `${escapeHTML(String(status.error_kind))} · ` : '';
box.innerHTML = `<strong>${escapeHTML(endpoint)}</strong>${kind}${escapeHTML(status.error || 'SearXNG-Test fehlgeschlagen')}`;
}
function renderAutonomousResearchStatus(status = {}) {
state.autonomousTaskStatus = status || {};
const badge = $('autonomousResearchBadge');
const feedback = $('autonomousResearchFeedback');
const runtime = state.runtimeSettings || {};
const enabled = Boolean(runtime.autonomous_research_enabled);
const running = Boolean(status.running);
if (badge) {
badge.textContent = running ? 'läuft' : enabled ? 'aktiv' : 'pausiert';
badge.className = running ? 'running' : enabled ? 'ok' : '';
}
if (feedback && !feedback.dataset.manual) {
const counts = status.counts || {};
const queued = Number(counts.queued || 0) + Number(counts.deferred || 0) + Number(counts.reserved || 0);
if (running) feedback.textContent = `Läuft: ${status.task_topic || status.task_id || 'Rechercheaufgabe'} · ${queued} weitere in der Queue.`;
else if (!enabled) feedback.textContent = 'Autonome Recherche ist pausiert. Manuelle Aufgaben bleiben in der SQLite-Queue erhalten.';
else feedback.textContent = `${queued} wartende Aufgaben · Intervall ${status.interval || ''} · Cooldown ${status.cooldown || ''}.`;
}
}
function scheduleAutonomousTasksLoad(delay = 180) {
clearTimeout(state.autonomousTasksTimer);
state.autonomousTasksTimer = setTimeout(() => {
state.autonomousTasksTimer = 0;
loadAutonomousResearchTasks().catch(() => {});
}, delay);
}
async function loadAutonomousResearchTasks() {
const payload = await api('/api/research/tasks?limit=24');
state.autonomousTasks = payload.tasks || [];
state.autonomousTaskStatus = payload.status || state.autonomousTaskStatus || {};
renderAutonomousResearchStatus(state.autonomousTaskStatus);
renderAutonomousResearchQueue();
}
function autonomousStatusLabel(status) {
return ({queued: 'wartet', reserved: 'reserviert', running: 'läuft', deferred: 'später', completed: 'fertig', failed: 'fehlgeschlagen', cancelled: 'abgebrochen'})[status] || status || 'unbekannt';
}
function renderAutonomousResearchQueue() {
const target = $('autonomousResearchQueue');
const count = $('autonomousQueueCount');
if (!target) return;
const tasks = state.autonomousTasks || [];
const activeCount = tasks.filter(task => ['queued', 'reserved', 'running', 'deferred'].includes(task.status)).length;
if (count) count.textContent = String(activeCount);
target.innerHTML = '';
if (!tasks.length) {
target.innerHTML = '<span class="empty-filter">Noch keine Rechercheaufgaben</span>';
return;
}
for (const task of tasks.slice(0, 16)) {
const item = document.createElement('div');
item.className = `autonomous-task ${escapeHTML(task.status || '')}`;
const priority = Math.round(Number(task.priority || 0) * 100);
const meta = [autonomousStatusLabel(task.status), `${priority}% Priorität`, `${Number(task.evidence_count || 0)} Belege`, `Versuch ${Number(task.attempts || 0)}/${Number(task.max_attempts || 0)}`];
if (task.article_created) meta.push('Artikel erstellt');
const cancel = ['queued', 'deferred', 'reserved'].includes(task.status) ? `<button type="button" data-cancel-research-task="${escapeHTML(task.id)}">ABBRECHEN</button>` : '';
item.innerHTML = `<div class="autonomous-task-head"><b>${escapeHTML(task.topic)}</b><em>${priority}%</em></div><p>${escapeHTML(task.reason || task.outcome || '')}</p><div class="autonomous-task-meta">${meta.map(value => `<span>${escapeHTML(value)}</span>`).join('')}</div>${cancel}`;
target.appendChild(item);
}
}
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 · Relationen und KB-Synthese · ${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} Relationsprüfungen pro Zyklus · Artikel ab ${status.article_min_sources || 3} produktiven Quellen · 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}`];
const articleModels = status.article_model_status || {};
if (articleModels.synthesis?.model || articleModels.review?.model) {
diagnostics.push(`Artikel ${articleModels.synthesis?.model || '?'}${articleModels.review?.model || '?'}`);
}
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;
state.honeySlotByID.clear();
state.honeyPointPool = [];
return;
}
const noteIDs = new Set(notes.map(node => node.id));
for (const id of [...state.honeySlotByID.keys()]) {
if (!noteIDs.has(id)) state.honeySlotByID.delete(id);
}
const desiredCapacity = Math.max(notes.length + 48, Math.ceil(notes.length * 1.18));
const rebuildPool = state.honeyPointPool.length < notes.length || !state.honeycombSpacing;
if (rebuildPool) {
let low = 0.008;
let high = 0.32;
while (honeycombPointCount(low, desiredCapacity) < desiredCapacity && low > 0.0025) low *= 0.75;
for (let i = 0; i < 12; i++) {
const mid = (low + high) / 2;
const count = honeycombPointCount(mid, desiredCapacity);
if (count >= desiredCapacity) low = mid; else high = mid;
}
const spacing = Math.max(0.0025, low * 0.985);
let points = honeycombPointCount(spacing, desiredCapacity, true);
if (points.length < notes.length) points = honeycombPointCount(Math.max(0.0025, spacing * 0.96), desiredCapacity, 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)}`));
const previous = new Map(notes.map(node => [node.id, Number.isFinite(node.honeyX) ? {x: node.honeyX, y: node.honeyY, z: node.honeyZ} : null]));
state.honeyPointPool = points;
state.honeySlotByID = new Map();
const ordered = [...notes].sort((a, b) => hashString(a.id) - hashString(b.id));
const step = points.length / ordered.length;
for (let i = 0; i < ordered.length; i++) {
const slot = Math.min(points.length - 1, Math.floor(i * step));
state.honeySlotByID.set(ordered[i].id, slot);
const old = previous.get(ordered[i].id);
if (old) {
ordered[i].honeyTransitionFrom = old;
ordered[i].honeyTransitionStart = performance.now();
}
}
state.honeycombSpacing = spacing;
}
const occupied = new Set(state.honeySlotByID.values());
const free = [];
for (let i = 0; i < state.honeyPointPool.length; i++) if (!occupied.has(i)) free.push(i);
const newcomers = notes.filter(node => !state.honeySlotByID.has(node.id)).sort((a, b) => hashString(a.id) - hashString(b.id));
for (const node of newcomers) {
if (!free.length) break;
const pick = Math.floor(pseudo(node.id, 144) * free.length);
const slot = free.splice(Math.min(free.length - 1, pick), 1)[0];
state.honeySlotByID.set(node.id, slot);
node._fadeInStart = node._fadeInStart || performance.now();
}
for (const node of notes) {
const slot = state.honeySlotByID.get(node.id);
const point = state.honeyPointPool[slot];
if (!point) continue;
node.honeyX = point.x;
node.honeyY = point.y;
node.honeyZ = point.z;
}
}
function refreshClusterLinks() {
for (const cluster of state.clusters) cluster.links = new Map();
for (const edge of state.edges) {
const a = state.nodeById.get(edge.source);
const b = state.nodeById.get(edge.target);
if (!a?.cluster || !b?.cluster || a.clusterKey === b.clusterKey) continue;
const weight = Math.max(0.05, Number(edge.weight || 1));
a.cluster.links.set(b.clusterKey, (a.cluster.links.get(b.clusterKey) || 0) + weight);
b.cluster.links.set(a.clusterKey, (b.cluster.links.get(a.clusterKey) || 0) + weight);
}
}
function buildConstellationLinks() {
const links = [];
const seen = new Set();
for (const cluster of state.clusters) {
for (const [targetKey, weight] of cluster.links || []) {
const target = state.clusterByKey.get(targetKey);
if (!target) continue;
const pair = cluster.key < targetKey ? `${cluster.key}\u0000${targetKey}` : `${targetKey}\u0000${cluster.key}`;
if (seen.has(pair)) continue;
seen.add(pair);
links.push({id: `constellation:${hashString(pair).toString(36)}`, source: cluster, target, weight});
}
}
links.sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));
state.constellationLinks = links.slice(0, state.lowPowerMode ? 48 : 120);
}
function buildConstellationLayout() {
state.constellationNodes = state.nodes;
const clusters = state.clusters;
const total = Math.max(1, clusters.length);
const now = performance.now();
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i];
if (!Number.isFinite(cluster.constellationX)) {
const u = 1 - 2 * ((i + 0.5) / total);
const ring = Math.sqrt(Math.max(0, 1 - u * u));
const angle = i * GOLDEN + pseudo(cluster.key, 301) * 0.45;
cluster.constellationX = Math.cos(angle) * ring * 0.78;
cluster.constellationY = u * 0.66;
cluster.constellationZ = Math.sin(angle) * ring * 0.54;
}
cluster.constellationRadius = Math.max(0.045, Math.min(0.17, 0.035 + Math.sqrt(cluster.size) * 0.006));
cluster.constellationPhase = pseudo(cluster.key, 302) * Math.PI * 2;
}
for (const cluster of clusters) {
for (const node of cluster.nodes) {
const old = Number.isFinite(node.constellationX) ? {x: node.constellationX, y: node.constellationY, z: node.constellationZ} : null;
const angle = pseudo(node.id, 311) * Math.PI * 2;
const radial = Math.sqrt(pseudo(node.id, 312)) * cluster.constellationRadius;
const tilt = (pseudo(node.id, 313) - 0.5) * cluster.constellationRadius * 0.72;
const eccentric = 0.72 + pseudo(node.id, 314) * 0.44;
node.constellationX = cluster.constellationX + Math.cos(angle) * radial * eccentric;
node.constellationY = cluster.constellationY + Math.sin(angle) * radial * 0.72;
node.constellationZ = cluster.constellationZ + tilt + Math.sin(angle * 2) * radial * 0.16;
if (old && Math.hypot(node.constellationX - old.x, node.constellationY - old.y, node.constellationZ - old.z) > 0.004) {
node.constellationTransitionFrom = old;
node.constellationTransitionStart = now;
}
}
}
buildConstellationLinks();
}
function nodePositionForView(node, mode = state.viewMode) {
if (mode === 'honeycomb' && Number.isFinite(node.honeyX)) return {x: node.honeyX, y: node.honeyY, z: node.honeyZ};
if (mode === 'constellation' && Number.isFinite(node.constellationX)) return {x: node.constellationX, y: node.constellationY, z: node.constellationZ};
return {x: Number(node.neuralX ?? node.x ?? 0), y: Number(node.neuralY ?? node.y ?? 0), z: Number(node.neuralZ ?? node.z ?? 0)};
}
function captureViewGhosts(now) {
const maxGhosts = state.lowPowerMode ? 900 : 2600;
const source = state.projected.length ? state.projected : state.renderNodes;
const step = Math.max(1, Math.ceil(source.length / maxGhosts));
state.viewGhostNodes = [];
for (let i = 0; i < source.length; i += step) {
const node = source[i];
if (!node?.screen) continue;
state.viewGhostNodes.push({
screen: {...node.screen}, color: node.clusterColor || [82, 231, 255], kind: node.kind,
startedAt: now, duration: TRANSITION_CONFIG.viewMS
});
}
}
function cloneRetiringNode(node, now) {
return {
...node,
screen: node.screen ? {...node.screen} : null,
cluster: node.cluster,
_fadeInStart: 0,
_fadeOutStart: now,
_fadeOutDuration: TRANSITION_CONFIG.exitMS,
transitionFrom: null,
honeyTransitionFrom: null,
constellationTransitionFrom: null
};
}
function pruneRetiringEntities(now) {
state.retiringRenderNodes = state.retiringRenderNodes.filter(node => now - node._fadeOutStart < (node._fadeOutDuration || TRANSITION_CONFIG.exitMS));
state.retiringRenderEdges = state.retiringRenderEdges.filter(edge => now - edge._fadeOutStart < (edge._fadeOutDuration || TRANSITION_CONFIG.exitMS));
const maxRetired = state.lowPowerMode ? 1000 : 4000;
if (state.retiringRenderNodes.length > maxRetired) state.retiringRenderNodes.splice(0, state.retiringRenderNodes.length - maxRetired);
state.retiringRenderNodeById = new Map(state.retiringRenderNodes.map(node => [node.id, node]));
}
function commitRenderGraph(renderNodes, renderEdges, renderIdleEdges, renderNodeById, renderEdgeById, visibleForNode, edgeRenderMap, stats, now, retireOld = true) {
if (retireOld) {
for (const [id, node] of state.renderNodeById) {
if (!renderNodeById.has(id)) state.retiringRenderNodes.push(cloneRetiringNode(node, now));
}
for (const [id, edge] of state.renderEdgeById) {
if (!renderEdgeById.has(id)) state.retiringRenderEdges.push({...edge, _fadeOutStart: now, _fadeOutDuration: TRANSITION_CONFIG.exitMS});
}
}
for (const node of renderNodes) {
if (!state.renderNodeById.has(node.id) && !node._fadeInStart) node._fadeInStart = now;
}
for (const edge of renderEdges) {
if (!state.renderEdgeById.has(edge.id) && !edge._fadeInStart) edge._fadeInStart = now;
}
state.renderNodes = renderNodes;
state.renderEdges = renderEdges;
state.renderIdleEdges = renderIdleEdges;
state.renderNodeById = renderNodeById;
state.renderEdgeById = renderEdgeById;
state.visibleForNode = visibleForNode;
state.edgeRenderMap = edgeRenderMap;
state.renderStats = stats;
pruneRetiringEntities(now);
}
function updateViewButtons() {
for (const mode of ['neural', 'honeycomb', 'constellation']) {
const button = $(`view${mode[0].toUpperCase()}${mode.slice(1)}`);
if (button) button.classList.toggle('active', state.viewMode === mode);
}
document.body.classList.toggle('honeycomb-view', state.viewMode === 'honeycomb');
document.body.classList.toggle('constellation-view', state.viewMode === 'constellation');
const edgeButton = $('toggleEdges');
const cortexButton = $('toggleCortex');
const lodButton = $('toggleLOD');
if (edgeButton) edgeButton.disabled = state.viewMode === 'honeycomb';
if (cortexButton) cortexButton.disabled = state.viewMode !== 'neural';
if (lodButton) lodButton.disabled = state.viewMode !== 'neural';
}
function applyViewMode(mode, persist = true) {
mode = ['neural', 'honeycomb', 'constellation'].includes(mode) ? mode : 'neural';
const previousMode = state.viewMode;
const changed = previousMode !== mode;
const now = performance.now();
if (changed) captureViewGhosts(now);
state.viewMode = mode;
state.runtimeSettings.view_mode = mode;
state.hover = null;
state.selected = null;
state.particles.length = Math.min(state.particles.length, state.lowPowerMode ? 80 : 240);
if (changed) {
state.retiringRenderNodes = [];
state.retiringRenderEdges = [];
for (const node of state.nodes) {
const from = nodePositionForView(node, previousMode);
node.transitionFrom = from;
node.transitionStart = now;
node._fadeInStart = now;
}
}
if (mode === 'honeycomb') {
const renderNodes = state.honeycombNodes;
const renderNodeById = new Map(renderNodes.map(node => [node.id, node]));
const visibleForNode = new Map(renderNodes.map(node => [node.id, node.id]));
commitRenderGraph(renderNodes, [], [], renderNodeById, new Map(), visibleForNode, new Map(), {
nodes: renderNodes.length, edges: 0,
hiddenNodes: Math.max(0, state.nodes.length - renderNodes.length), hiddenEdges: state.edges.length
}, now, !changed);
} else if (mode === 'constellation') {
const renderNodes = state.constellationNodes;
const renderNodeById = new Map(renderNodes.map(node => [node.id, node]));
const visibleForNode = new Map(renderNodes.map(node => [node.id, node.id]));
commitRenderGraph(renderNodes, [], [], renderNodeById, new Map(), visibleForNode, new Map(), {
nodes: renderNodes.length, edges: state.constellationLinks.length,
hiddenNodes: Math.max(0, state.nodes.length - renderNodes.length), hiddenEdges: Math.max(0, state.edges.length - state.constellationLinks.length)
}, now, !changed);
} else {
state.lodDirty = true;
rebuildRenderGraph(now, true, !changed);
}
const renderCount = $('renderCount');
if (renderCount) renderCount.textContent = state.renderStats.nodes.toLocaleString('de-DE');
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),
constellationX: previous?.constellationX, constellationY: previous?.constellationY, constellationZ: previous?.constellationZ,
constellationPhase: previous?.constellationPhase
};
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.phase = pseudo(cluster.key, 81) * Math.PI * 2;
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);
const newClusterCount = clusterList.filter(cluster => !cluster.hasPrevious).length;
const relaxIterations = newClusterCount ? (state.lowPowerMode ? 28 : 48) : (state.lowPowerMode ? 3 : 8);
relaxClusters(clusterList, relaxIterations);
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, iterations = 48) {
for (let iter = 0; iter < iterations; 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 previous = previousNodes.get(node.id);
if (previous?.clusterKey === cluster.key && Number.isFinite(previous.neuralX ?? previous.x)) {
node.x = Number(previous.neuralX ?? previous.x);
node.y = Number(previous.neuralY ?? previous.y);
node.z = Number(previous.neuralZ ?? previous.z);
continue;
}
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);
node.x = pos.x;
node.y = pos.y;
node.z = 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, retireOld = true) {
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;
}
const renderIdleEdges = renderEdges.filter(edge => edge.edgeCount > 1 || edge.origin === 'ai-inference' || edge.type === 'contradicts' || edge.type === 'supports');
const renderEdgeById = new Map(renderEdges.map(edge => [edge.id, edge]));
const stats = {
nodes: renderNodes.length,
edges: renderEdges.length,
hiddenNodes: Math.max(0, state.nodes.length - renderNodes.length),
hiddenEdges: Math.max(0, state.edges.length - renderEdges.length)
};
commitRenderGraph(renderNodes, renderEdges, renderIdleEdges, renderNodeById, renderEdgeById, visibleForNode, edgeRenderMap, stats, now, retireOld);
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));
}
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 prepareCameraFrame() {
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40));
state.cameraFrame = {
cy: Math.cos(state.yaw), sy: Math.sin(state.yaw),
cp: Math.cos(state.pitch), sp: Math.sin(state.pitch),
centerX: state.width / 2 + panelOffset,
centerY: state.height / 2 - 4,
scale: Math.min(usableW * 0.42, state.height * 0.45) * state.zoom
};
}
function projectCoordinates(x, y, z) {
const camera = state.cameraFrame || (prepareCameraFrame(), state.cameraFrame);
const x1 = x * camera.cy - z * camera.sy;
const z1 = x * camera.sy + z * camera.cy;
const y1 = y * camera.cp - z1 * camera.sp;
const z2 = y * camera.sp + z1 * camera.cp;
const perspective = 2.9 / (3.3 - z2 * 0.42);
return {x: camera.centerX + x1 * camera.scale * perspective, y: camera.centerY + y1 * camera.scale * perspective, z: z2, p: perspective};
}
function rotatePoint(n) {
const camera = state.cameraFrame || (prepareCameraFrame(), state.cameraFrame);
const x1 = n.x * camera.cy - n.z * camera.sy;
const z1 = n.x * camera.sy + n.z * camera.cy;
const y1 = n.y * camera.cp - z1 * camera.sp;
const z2 = n.y * camera.sp + z1 * camera.cp;
return {x: x1, y: y1, z: z2};
}
function project(n) {
return projectCoordinates(n.x, n.y, n.z);
}
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 point = nodePositionForView(node, state.viewMode);
const x = point.x;
const y = point.y;
const z = point.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 !== 'neural') 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') || evt.type?.startsWith('article.')) 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 researchEventID(evt) {
return String(evt?.metadata?.research_id || `${evt?.type || 'research'}:${evt?.metadata?.research_query || evt?.query || ++state.researchSequence}`);
}
function researchResultIDs(evt) {
const explicit = Array.isArray(evt?.metadata?.result_node_ids) ? evt.metadata.result_node_ids : [];
if (explicit.length) return explicit.map(String);
return (evt?.node_ids || []).filter(id => state.fullNodeById.get(id)?.kind === 'external' || state.nodeById.get(id)?.kind === 'external');
}
function researchSourceIDs(evt) {
const explicit = Array.isArray(evt?.metadata?.source_node_ids) ? evt.metadata.source_node_ids : [];
if (explicit.length) return explicit.map(String);
const resultIDs = new Set(researchResultIDs(evt));
return (evt?.node_ids || []).filter(id => !resultIDs.has(id));
}
function brainViewportCenter() {
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
return {x: state.width / 2 + panelOffset, y: state.height / 2 - 4};
}
function visibleScreenForNode(id) {
const visibleID = state.visibleForNode.get(id);
const renderNode = visibleID ? state.renderNodeById.get(visibleID) : null;
if (renderNode?.screen) return renderNode.screen;
const node = state.nodeById.get(id);
if (node?.screen) return node.screen;
return null;
}
function averageScreen(ids, fallback) {
let x = 0, y = 0, count = 0;
for (const id of ids || []) {
const point = visibleScreenForNode(id);
if (!point) continue;
x += point.x;
y += point.y;
count++;
}
return count ? {x: x / count, y: y / count} : fallback;
}
function researchSourcePoints(animation, count) {
const desired = Math.max(3, Math.min(state.performanceProfile.researchSources, count || 5));
if (animation.sourcePoints?.length === desired) return animation.sourcePoints;
const center = brainViewportCenter();
const rx = Math.min(state.width * 0.34, state.height * 0.43);
const ry = Math.min(state.height * 0.39, state.width * 0.28);
const seed = hashString(animation.id);
animation.sourcePoints = Array.from({length: desired}, (_, index) => {
const angle = (index / desired) * Math.PI * 2 + (seed % 1000) / 1000 * Math.PI * 2;
return {
x: center.x + Math.cos(angle) * rx * (0.9 + pseudo(animation.id, index + 40) * 0.18),
y: center.y + Math.sin(angle) * ry * (0.88 + pseudo(animation.id, index + 70) * 0.2),
phase: pseudo(animation.id, index + 100) * Math.PI * 2
};
});
return animation.sourcePoints;
}
function handleResearchAnimation(evt) {
if (!evt?.type?.includes('research')) return;
if (!evt?.metadata?.research_id) return;
const timestamp = Date.parse(evt.timestamp || '') || Date.now();
const id = researchEventID(evt);
let animation = state.researchAnimations.get(id);
if (!animation && Date.now() - timestamp > 30000) return;
const now = performance.now();
if (!animation) {
animation = {
id,
stage: 'searching',
startedAt: now,
updatedAt: now,
until: Number.POSITIVE_INFINITY,
query: String(evt.metadata?.research_query || evt.query || ''),
sourceNodeIDs: researchSourceIDs(evt),
resultNodeIDs: [],
resultCount: 0,
titles: [],
domains: [],
sourcePoints: [],
fallbackAnchor: brainViewportCenter()
};
state.researchAnimations.set(id, animation);
}
animation.updatedAt = now;
animation.query = String(evt.metadata?.research_query || animation.query || '');
const sourceIDs = researchSourceIDs(evt);
if (sourceIDs.length) animation.sourceNodeIDs = [...new Set([...(animation.sourceNodeIDs || []), ...sourceIDs])];
const minMS = Math.max(2000, Number(evt.metadata?.animation_min_ms || 2000));
if (evt.type.endsWith('.started')) {
animation.stage = 'searching';
animation.until = Number.POSITIVE_INFINITY;
} else if (evt.type.endsWith('.results')) {
animation.resultCount = Number(evt.metadata?.result_count || 0);
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : [];
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : [];
animation.stage = animation.resultCount > 0 ? 'results' : 'empty';
animation.resultsAt = now;
animation.until = now + minMS;
researchSourcePoints(animation, animation.resultCount || 4);
} else if (evt.type.endsWith('.ingested')) {
const resultIDs = researchResultIDs(evt);
animation.resultNodeIDs = [...new Set([...(animation.resultNodeIDs || []), ...resultIDs])];
animation.resultCount = Number(evt.metadata?.result_count || animation.resultCount || resultIDs.length);
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : animation.titles;
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : animation.domains;
animation.stage = 'ingested';
animation.ingestedAt = now;
animation.until = Number.POSITIVE_INFINITY;
researchSourcePoints(animation, animation.resultCount || resultIDs.length || 4);
refreshResearchNodes(animation, minMS);
} else if (evt.type === 'research.failed' || evt.type === 'article.research.failed' || evt.type === 'research.test.failed') {
animation.stage = 'failed';
animation.error = String(evt.metadata?.error || 'Recherche fehlgeschlagen');
animation.until = now + minMS;
} else if (evt.type === 'article.research.completed') {
animation.resultCount = Number(evt.metadata?.accepted_count || animation.resultCount || 0);
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : animation.titles;
animation.stage = animation.resultCount > 0 ? 'ingested' : 'empty';
animation.until = now + minMS;
}
}
async function refreshResearchNodes(animation, minMS) {
try {
await loadGraph();
const ids = animation.resultNodeIDs || [];
forceDisplayNodes(ids, Math.max(6000, minMS + 4000));
if (state.fullSnapshot && ids.some(id => !state.nodeById.has(id))) applyGraphSnapshot(state.fullSnapshot, true);
for (const id of ids) {
state.active.set(id, Math.max(state.active.get(id) || 0, 1.15));
const visibleID = state.visibleForNode.get(id);
if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, 1.15));
}
animation.stage = 'ingested';
animation.ingestedAt = performance.now();
animation.until = animation.ingestedAt + Math.max(2000, minMS);
} catch {
animation.until = Math.max(animation.until || 0, performance.now() + Math.max(2000, minMS));
}
}
function drawResearchHex(x, y, radius, color, alpha, rotation = 0) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = rotation + i / 6 * Math.PI * 2;
const px = x + Math.cos(angle) * radius;
const py = y + Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
function renderResearchAnimations(now) {
if (!state.researchAnimations.size) return;
const green = MODE_CONFIG.researching.color;
ctx.save();
ctx.globalCompositeOperation = 'screen';
for (const [id, animation] of state.researchAnimations) {
if (Number.isFinite(animation.until) && now > animation.until) {
state.researchAnimations.delete(id);
continue;
}
const anchor = averageScreen(animation.sourceNodeIDs, animation.fallbackAnchor || brainViewportCenter());
animation.fallbackAnchor = anchor;
const elapsed = Math.max(0, (now - animation.startedAt) / 1000);
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6.2);
const searching = animation.stage === 'searching';
const failed = animation.stage === 'failed';
const empty = animation.stage === 'empty';
const color = failed || empty ? [255, 180, 82] : green;
const ringAlpha = searching ? 0.34 : 0.22;
ctx.lineWidth = 1.1;
ctx.setLineDash([4, 8]);
for (let ring = 0; ring < 3; ring++) {
const radius = 24 + ring * 13 + pulse * 4;
ctx.strokeStyle = rgba(color, ringAlpha - ring * 0.055);
ctx.beginPath();
ctx.arc(anchor.x, anchor.y, radius, elapsed * (0.7 + ring * 0.18), elapsed * (0.7 + ring * 0.18) + Math.PI * (1.15 + ring * 0.12));
ctx.stroke();
}
ctx.setLineDash([]);
const sweepAngle = elapsed * 2.7;
ctx.strokeStyle = rgba(color, 0.35 + pulse * 0.18);
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(anchor.x, anchor.y);
ctx.lineTo(anchor.x + Math.cos(sweepAngle) * 58, anchor.y + Math.sin(sweepAngle) * 58);
ctx.stroke();
const sourceCount = searching ? 5 : Math.max(1, animation.resultCount || animation.resultNodeIDs?.length || 1);
const sourcePoints = researchSourcePoints(animation, sourceCount);
const resultTargets = (animation.resultNodeIDs || []).map(visibleScreenForNode).filter(Boolean);
for (let index = 0; index < sourcePoints.length; index++) {
const source = sourcePoints[index];
const target = resultTargets[index % Math.max(1, resultTargets.length)] || anchor;
const sourcePulse = 0.5 + 0.5 * Math.sin(elapsed * 5 + source.phase);
ctx.strokeStyle = rgba(color, searching ? 0.055 + sourcePulse * 0.04 : 0.12 + sourcePulse * 0.06);
ctx.lineWidth = 0.65;
ctx.beginPath();
ctx.moveTo(source.x, source.y);
const mx = (source.x + target.x) / 2 + Math.sin(source.phase) * 24;
const my = (source.y + target.y) / 2 - 22;
ctx.quadraticCurveTo(mx, my, target.x, target.y);
ctx.stroke();
drawResearchHex(source.x, source.y, 4.2 + sourcePulse * 1.7, color, 0.28 + sourcePulse * 0.34, elapsed * 0.35 + source.phase);
if (!searching && !failed && !empty) {
const phase = ((now - (animation.resultsAt || animation.startedAt)) / 760 + index * 0.17) % 1;
const eased = phase * phase * (3 - 2 * phase);
const packetX = source.x + (target.x - source.x) * eased;
const packetY = source.y + (target.y - source.y) * eased - Math.sin(phase * Math.PI) * 26;
if (state.lowPowerMode) {
ctx.fillStyle = rgba(color, 0.72);
ctx.beginPath();
ctx.arc(packetX, packetY, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.9)';
ctx.beginPath();
ctx.arc(packetX, packetY, 1.1, 0, Math.PI * 2);
ctx.fill();
} else {
const glow = ctx.createRadialGradient(packetX, packetY, 0, packetX, packetY, 9);
glow.addColorStop(0, 'rgba(255,255,255,.96)');
glow.addColorStop(0.22, rgba(color, 0.85));
glow.addColorStop(1, rgba(color, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(packetX, packetY, 9, 0, Math.PI * 2);
ctx.fill();
}
}
}
if (animation.stage === 'ingested') {
const settle = Math.max(0, Math.min(1, (now - animation.ingestedAt) / 2000));
for (let index = 0; index < resultTargets.length; index++) {
const target = resultTargets[index];
const radius = 12 + (1 - settle) * 22 + Math.sin(elapsed * 8 + index) * 2;
ctx.lineWidth = 1.2;
drawResearchHex(target.x, target.y, radius, green, 0.5 * (1 - settle * 0.45), elapsed * 0.5 + index);
ctx.strokeStyle = rgba(green, 0.24 * (1 - settle));
ctx.beginPath();
ctx.arc(target.x, target.y, radius * 1.45, 0, Math.PI * 2);
ctx.stroke();
}
}
ctx.save();
ctx.globalCompositeOperation = 'source-over';
const label = searching ? 'SEARXNG · QUELLENSUCHE' : failed ? 'SEARXNG · FEHLER' : empty ? 'SEARXNG · 0 QUELLEN' : animation.stage === 'ingested' ? `SEARXNG · ${animation.resultCount} QUELLEN VERKNÜPFT` : `SEARXNG · ${animation.resultCount} QUELLEN`;
ctx.font = '700 9px Inter, system-ui';
const width = ctx.measureText(label).width + 16;
ctx.fillStyle = 'rgba(2,10,16,.88)';
ctx.fillRect(anchor.x - width / 2, anchor.y + 49, width, 18);
ctx.strokeStyle = rgba(color, 0.35);
ctx.strokeRect(anchor.x - width / 2, anchor.y + 49, width, 18);
ctx.fillStyle = rgba(color, 0.95);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, anchor.x, anchor.y + 58);
ctx.restore();
}
ctx.restore();
}
function projectAnimatedNode(node, now) {
const mode = state.viewMode;
const point = nodePositionForView(node, mode);
let x = point.x;
let y = point.y;
let z = point.z;
let from = node.transitionFrom;
let started = node.transitionStart;
if (mode === 'honeycomb' && node.honeyTransitionFrom) {
from = node.honeyTransitionFrom;
started = node.honeyTransitionStart;
} else if (mode === 'constellation' && node.constellationTransitionFrom) {
from = node.constellationTransitionFrom;
started = node.constellationTransitionStart;
}
if (from && started) {
const t = Math.max(0, Math.min(1, (now - started) / TRANSITION_CONFIG.moveMS));
const eased = t * t * (3 - 2 * t);
x = from.x + (x - from.x) * eased;
y = from.y + (y - from.y) * eased;
z = from.z + (z - from.z) * eased;
if (t >= 1) {
if (mode === 'honeycomb') {
node.honeyTransitionFrom = null;
node.honeyTransitionStart = 0;
} else if (mode === 'constellation') {
node.constellationTransitionFrom = null;
node.constellationTransitionStart = 0;
}
node.transitionFrom = null;
node.transitionStart = 0;
}
}
const cluster = node.cluster;
if (cluster && mode === 'neural') {
const clusterEnergy = state.clusterActive.get(cluster.key) || 0;
const learning = state.mode === 'learning';
const ambient = 1 + Math.sin(now * 0.00072 + (cluster.phase || 0)) * (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;
} else if (cluster && mode === 'constellation') {
const clusterEnergy = state.clusterActive.get(cluster.key) || 0;
const focus = cluster.key === state.focusClusterKey ? state.activityEnergy : 0;
const orbit = 1 + Math.sin(now * 0.00052 + cluster.constellationPhase) * 0.009 + clusterEnergy * 0.025 + focus * 0.016;
x = cluster.constellationX + (x - cluster.constellationX) * orbit;
y = cluster.constellationY + (y - cluster.constellationY) * orbit;
z = cluster.constellationZ + (z - cluster.constellationZ) * orbit;
}
return projectCoordinates(x, y, z);
}
function entityFadeAlpha(entity, now) {
if (entity?._fadeOutStart) {
return Math.max(0, 1 - (now - entity._fadeOutStart) / (entity._fadeOutDuration || TRANSITION_CONFIG.exitMS));
}
if (entity?._fadeInStart) {
const value = Math.max(0, Math.min(1, (now - entity._fadeInStart) / TRANSITION_CONFIG.enterMS));
if (value >= 1) entity._fadeInStart = 0;
return value * value * (3 - 2 * value);
}
return 1;
}
function drawBackground(now) {
rebuildBackgroundCache();
ctx.drawImage(state.backgroundCanvas, 0, 0, state.width, state.height);
const movingStars = state.lowPowerMode ? 8 : 22;
ctx.save();
ctx.globalAlpha = state.lowPowerMode ? 0.08 : 0.12;
for (let i = 0; i < movingStars; i++) {
const x = (pseudo(`moving:${i}`, 1) * state.width + now * 0.003 * (i % 3 + 1)) % state.width;
const y = pseudo(`moving:${i}`, 2) * state.height;
ctx.fillStyle = i % 7 === 0 ? '#52e7ff' : '#6a8194';
ctx.fillRect(x, y, i % 9 === 0 ? 1.4 : 0.7, i % 9 === 0 ? 1.4 : 0.7);
}
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 !== 'neural') return;
ctx.save();
ctx.globalCompositeOperation = 'screen';
const clouds = state.lowPowerMode
? state.clusters.filter(cluster => cluster.importance > 0.55 || cluster.key === state.focusClusterKey || (state.clusterActive.get(cluster.key) || 0) > 0.08).slice(0, state.performanceProfile.clouds)
: state.clusters;
for (const cluster of clouds) {
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 + (cluster.phase || 0)) * 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 renderConstellationGuides(now) {
if (state.viewMode !== 'constellation') return;
const visibleClusters = state.lowPowerMode ? state.clusters.slice(0, state.performanceProfile.clouds) : state.clusters;
for (const cluster of visibleClusters) {
cluster.screen = projectCoordinates(cluster.constellationX, cluster.constellationY, cluster.constellationZ);
}
ctx.save();
ctx.globalCompositeOperation = 'screen';
const linkLimit = state.lowPowerMode ? 34 : 96;
const visibleLinkCount = state.edgesVisible ? Math.min(linkLimit, state.constellationLinks.length) : 0;
for (let i = 0; i < visibleLinkCount; i++) {
const link = state.constellationLinks[i];
const a = link.source.screen;
const b = link.target.screen;
if (!a || !b) continue;
const focus = link.source.key === state.focusClusterKey || link.target.key === state.focusClusterKey;
const active = Math.max(state.clusterActive.get(link.source.key) || 0, state.clusterActive.get(link.target.key) || 0);
const alpha = Math.min(0.24, 0.018 + Math.log1p(link.weight) * 0.018 + active * 0.09 + (focus ? 0.08 : 0));
if (alpha < 0.022 && state.lowPowerMode) continue;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2 - Math.abs(a.x - b.x) * 0.045;
ctx.strokeStyle = focus ? rgba(MODE_CONFIG.thinking.color, alpha + 0.07) : 'rgba(82,181,255,' + alpha + ')';
ctx.lineWidth = 0.45 + Math.min(1.3, Math.log1p(link.weight) * 0.16) + active * 0.8;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.quadraticCurveTo(mx, my, b.x, b.y);
ctx.stroke();
}
for (const cluster of visibleClusters) {
const p = cluster.screen;
if (!p) continue;
const active = state.clusterActive.get(cluster.key) || 0;
const focus = cluster.key === state.focusClusterKey ? state.activityEnergy : 0;
const pulse = 1 + Math.sin(now * 0.001 + cluster.constellationPhase) * 0.035;
const radius = Math.max(18, cluster.constellationRadius * state.cameraFrame.scale * p.p * 1.12 * pulse);
const alpha = 0.05 + cluster.importance * 0.025 + active * 0.12 + focus * 0.12;
if (!state.lowPowerMode || active > 0.05 || focus > 0.05 || cluster.importance > 0.62) {
ctx.strokeStyle = clusterFill(cluster, alpha);
ctx.lineWidth = 0.65 + active * 1.2 + focus;
ctx.setLineDash(state.lowPowerMode ? [3, 10] : [3, 7]);
ctx.beginPath();
ctx.ellipse(p.x, p.y, radius, radius * 0.56, cluster.constellationPhase * 0.35, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
const core = state.lowPowerMode ? 3.2 : 4.4;
ctx.fillStyle = clusterFill(cluster, 0.42 + active * 0.35 + focus * 0.25);
ctx.beginPath();
ctx.arc(p.x, p.y, core + active * 2.5 + focus * 1.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function renderConstellationLabels() {
if (state.viewMode !== 'constellation' || state.width < 760) return;
const maxLabels = state.lowPowerMode ? 6 : state.width > 1450 ? 12 : 8;
const clusters = state.clusters
.filter(cluster => cluster.screen && (cluster.importance > 0.5 || cluster.key === state.focusClusterKey || (state.clusterActive.get(cluster.key) || 0) > 0.2))
.sort((a, b) => (b.key === state.focusClusterKey) - (a.key === state.focusClusterKey) || b.mass - a.mass)
.slice(0, maxLabels);
ctx.save();
ctx.font = '600 9px Inter, system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const cluster of clusters) {
const p = cluster.screen;
const text = `${cluster.label.length > 25 ? cluster.label.slice(0, 23) + '…' : cluster.label} · ${cluster.size}`;
const w = ctx.measureText(text).width + 14;
ctx.fillStyle = 'rgba(2,8,16,.76)';
ctx.fillRect(p.x - w / 2, p.y + 13, w, 16);
ctx.strokeStyle = clusterFill(cluster, cluster.key === state.focusClusterKey ? 0.62 : 0.22);
ctx.strokeRect(p.x - w / 2, p.y + 13, w, 16);
ctx.fillStyle = clusterFill(cluster, 0.86);
ctx.fillText(text, p.x, p.y + 21);
}
ctx.restore();
}
function renderViewGhosts(now) {
if (!state.viewGhostNodes.length) return;
ctx.save();
ctx.globalCompositeOperation = 'screen';
state.viewGhostNodes = state.viewGhostNodes.filter(ghost => now - ghost.startedAt < ghost.duration);
for (const ghost of state.viewGhostNodes) {
const t = Math.max(0, Math.min(1, (now - ghost.startedAt) / ghost.duration));
const alpha = (1 - t) * (state.lowPowerMode ? 0.16 : 0.24);
ctx.fillStyle = rgba(ghost.color || [82, 231, 255], alpha);
ctx.beginPath();
ctx.arc(ghost.screen.x, ghost.screen.y, ghost.kind === 'supernode' ? 3.4 : 1.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function renderCortexLabels() {
if (state.viewMode !== 'neural') 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(now) {
if (state.viewMode !== 'neural' || !state.edgesVisible) return;
ctx.save();
ctx.globalCompositeOperation = 'screen';
const maxIdle = state.performanceProfile.idleEdges;
const idle = state.renderIdleEdges;
const stride = idle.length > maxIdle ? Math.ceil(idle.length / maxIdle) : 1;
const included = new Set();
const drawEdge = (edge, fade = 1, retiring = false) => {
const lookup = retiring ? id => state.renderNodeById.get(id) || state.retiringRenderNodeById.get(id) : id => state.renderNodeById.get(id);
const a = lookup(edge.source);
const b = lookup(edge.target);
if (!a?.screen || !b?.screen || fade <= 0.01) return;
const active = retiring ? 0 : 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) * fade;
if (alpha < 0.01) return;
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();
};
for (let i = 0; i < idle.length; i += stride) {
const edge = idle[i];
included.add(edge.id);
drawEdge(edge, entityFadeAlpha(edge, now), false);
}
for (const id of state.renderEdgeActive.keys()) {
if (included.has(id)) continue;
const edge = state.renderEdgeById.get(id);
if (edge) drawEdge(edge, entityFadeAlpha(edge, now), false);
}
for (const edge of state.retiringRenderEdges) {
const fade = entityFadeAlpha(edge, now);
drawEdge(edge, fade, true);
}
ctx.restore();
}
function renderParticles(dt) {
if (state.viewMode !== 'neural') return;
const maxParticles = state.performanceProfile.particles;
if (state.particles.length > maxParticles) state.particles.splice(0, state.particles.length - maxParticles);
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);
if (state.lowPowerMode) {
ctx.fillStyle = particle.color;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.arc(x, y, Math.max(1.6, size * 0.38), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(255,255,255,.88)';
ctx.beginPath();
ctx.arc(x, y, Math.max(0.8, size * 0.12), 0, Math.PI * 2);
ctx.fill();
} else {
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);
}
pruneRetiringEntities(now);
const candidates = [];
for (const node of state.renderNodes) {
node._renderKey = `live:${node.id}`;
node.screen = projectAnimatedNode(node, now);
candidates.push(node);
}
for (const node of state.retiringRenderNodes) {
node._renderKey = node._renderKey || `retired:${node.id}:${node._fadeOutStart}`;
node.screen = projectAnimatedNode(node, now);
candidates.push(node);
}
const shouldSort = !state.lowPowerMode || now >= state.projectedSortAt || state.projected.length !== candidates.length;
if (shouldSort) {
candidates.sort((a, b) => a.screen.z - b.screen.z);
state.projected = candidates;
state.projectedSortAt = now + (state.lowPowerMode ? (state.viewMode === 'neural' ? 140 : 600) : 0);
} else {
const byKey = new Map(candidates.map(node => [node._renderKey, node]));
const ordered = [];
for (const old of state.projected) {
const node = byKey.get(old._renderKey);
if (node) {
ordered.push(node);
byKey.delete(old._renderKey);
}
}
ordered.push(...byKey.values());
state.projected = ordered;
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const ecoBatched = new Set();
if (state.lowPowerMode && state.viewMode !== 'neural') {
const buckets = new Map();
for (const node of state.projected) {
if (node._fadeOutStart || entityFadeAlpha(node, now) < 0.98 || node.kind === 'supernode') continue;
const act = state.renderActive.get(node.id) || state.active.get(node.id) || 0;
if (act > 0.04 || state.hover?.id === node.id || state.selected?.id === node.id) continue;
const key = nodeColor(node, state.viewMode === 'honeycomb' ? 0.28 : 0.31);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(node);
ecoBatched.add(node._renderKey);
}
for (const [fill, nodes] of buckets) {
ctx.fillStyle = fill;
ctx.beginPath();
for (const node of nodes) {
const size = Math.max(0.75, Math.min(1.75, 0.65 + node.screen.p * 0.7));
ctx.rect(node.screen.x - size, node.screen.y - size, size * 2, size * 2);
}
ctx.fill();
}
}
for (const node of state.projected) {
if (ecoBatched.has(node._renderKey)) continue;
const honeycomb = state.viewMode === 'honeycomb';
const constellation = state.viewMode === 'constellation';
const isGroup = node.kind === 'supernode';
const retiring = Boolean(node._fadeOutStart);
const fade = entityFadeAlpha(node, now);
if (fade <= 0.01) continue;
const act = retiring ? 0 : state.renderActive.get(node.id) || (isGroup ? 0 : state.active.get(node.id) || 0);
const hover = !retiring && state.hover?.id === node.id;
const selected = !retiring && state.selected?.id === node.id;
const degree = state.viewMode === 'neural' ? Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length) : 0;
const memberScale = isGroup ? Math.min(7.5, 1.1 + Math.log2((node.memberCount || 1) + 1) * 0.72) : 0;
const baseWeight = honeycomb ? 0.72 : constellation ? 0.62 : 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 + Number(node.x || 0) * 7 + Number(node.y || 0) * 9);
const clusterEnergy = state.viewMode === 'neural' || constellation ? state.clusterActive.get(node.clusterKey) || 0 : 0;
const focused = 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 : constellation ? 0.25 : (isGroup ? 0.42 : 0.17);
const alpha = Math.min(1, baseAlpha - idleDim + node.screen.p * (honeycomb || constellation ? 0.04 : 0.1) + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0)) * fade;
const haloWanted = act > 0.05 || hover || selected || (!state.lowPowerMode && state.viewMode === 'neural' && (node.kind === 'ai-think' || isGroup));
if (haloWanted) {
const haloRadius = r * (isGroup ? 2.35 + act * 2.4 : 3 + act * 4);
if (state.lowPowerMode) {
ctx.strokeStyle = nodeColor(node, Math.min(0.65, (0.16 + act * 0.34) * fade));
ctx.lineWidth = 0.8 + act * 1.2;
ctx.beginPath();
ctx.arc(node.screen.x, node.screen.y, haloRadius * 0.58, 0, Math.PI * 2);
ctx.stroke();
} else {
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) * fade));
halo.addColorStop(0.24, nodeColor(node, ((isGroup ? 0.09 : 0.14) + act * 0.2) * fade));
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) * fade);
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 (!state.lowPowerMode && 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,${0.82 * fade})`;
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 (state.viewMode === 'neural' && node.kind === 'ai-think') {
ctx.strokeStyle = nodeColor(node, 0.55 * fade);
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';
let labelsDrawn = 0;
const maxLabels = state.lowPowerMode ? 12 : 36;
for (const node of state.projected) {
if (node._fadeOutStart || labelsDrawn >= maxLabels) continue;
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 === 'neural' && !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);
labelsDrawn++;
}
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 updateFPS(now) {
state.fpsFrames++;
const elapsed = now - state.fpsWindowStarted;
if (elapsed < 750) return;
state.fps = Math.round(state.fpsFrames * 1000 / elapsed);
state.fpsFrames = 0;
state.fpsWindowStarted = now;
const output = $('fpsCount');
if (output) {
output.textContent = String(state.fps);
output.parentElement.title = `${state.fps} FPS · ${state.lowPowerMode ? 'Eco-Modus' : 'volle Qualität'} · DPR ${state.dpr.toFixed(2)}`;
}
}
function frame(now) {
const minFrameMS = state.performanceProfile.minFrameMS;
if (minFrameMS && state.lastPaint && now - state.lastPaint < minFrameMS) {
requestAnimationFrame(frame);
return;
}
state.lastPaint = now;
const dt = Math.min(0.075, (now - state.last) / 1000);
state.last = now;
updateFPS(now);
updateVisualState(now, dt);
expireForcedDisplayNodes();
if (state.viewMode === 'neural') updateLOD(now);
prepareCameraFrame();
drawBackground(now);
renderViewGhosts(now);
if (state.viewMode === 'neural') {
renderActivityAura(now);
renderClusterClouds(now);
renderEdges(now);
renderParticles(dt);
} else if (state.viewMode === 'constellation') {
renderActivityAura(now);
renderConstellationGuides(now);
}
renderNodes(now, dt);
renderResearchAnimations(now);
if (state.viewMode === 'neural') {
renderWaves(dt);
renderCortexLabels();
} else if (state.viewMode === 'constellation') {
renderConstellationLabels();
state.waves.length = 0;
state.bursts.length = 0;
} 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) {
handleResearchAnimation(evt);
const requestedDuration = evt?.type?.includes('think') || evt?.type?.includes('research') ? 45000 : 32000;
forceDisplayNodes(evt?.node_ids || [], requestedDuration);
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 baseCount = state.lowPowerMode ? (mode === 'thinking' ? 3 : 2) : (mode === 'thinking' ? 7 : 5);
const count = evt.type === 'brain.idle' ? 1 : Math.ceil((state.lowPowerMode ? 1 : 3) + strength * baseCount);
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') {
scheduleGraphLoad(180);
scheduleSourceOptionsLoad(320);
}
if (evt.type?.startsWith('think.') || evt.type?.startsWith('article.') || evt.type?.startsWith('research.') || evt.type?.startsWith('autonomous.research.')) { loadStatus(); scheduleAutonomousTasksLoad(); }
}
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.relation.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'research.results', 'research.ingested', 'research.failed', 'research.test.started', 'research.test.results', 'research.test.failed', 'article.plan.started', 'article.plan.skipped', 'article.research.round.started', 'article.research.round.completed', 'article.research.reused', 'article.research.started', 'article.research.results', 'article.research.candidates', 'article.research.fetch.started', 'article.research.fetch.completed', 'article.research.fetch.failed', 'article.research.evidence.accepted', 'article.research.evidence.rejected', 'article.research.ingested', 'article.research.learned', 'article.research.completed', 'article.research.failed', 'article.draft.started', 'article.draft.rejected', 'article.created', 'article.duplicate', 'article.skipped', 'article.failed', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed', 'autonomous.research.scan.started', 'autonomous.research.scan.completed', 'autonomous.research.scan.failed', 'autonomous.research.task.queued', 'autonomous.research.task.started', 'autonomous.research.task.completed', 'autonomous.research.task.failed', 'autonomous.research.task.cancelled']);
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.metadata?.research_query || ''}|${evt.source || ''}|${evt.metadata?.research_id || ''}|${evt.metadata?.result_url || ''}|${evt.metadata?.round || evt.metadata?.research_round || ''}`;
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.relation.created': 'Relation übernommen',
'think.rejected': 'AI-THINK verwirft Edge',
'think.failed': 'AI-THINK Fehler',
'think.paused': 'AI-THINK pausiert',
'research.started': 'Relationsrecherche gestartet',
'research.results': 'SearXNG-Ergebnisse empfangen',
'research.ingested': 'Webquellen im Graph verknüpft',
'research.failed': 'Relationsrecherche fehlgeschlagen',
'research.test.started': 'SearXNG-Test gestartet',
'research.test.results': 'SearXNG-Test erfolgreich',
'research.test.failed': 'SearXNG-Test fehlgeschlagen',
'article.plan.started': 'Artikelmehrwert wird geprüft',
'article.plan.skipped': 'Artikelsynthese übersprungen',
'article.research.round.started': 'Recherche-Runde gestartet',
'article.research.round.completed': 'Recherche-Runde bewertet',
'article.research.reused': 'Gelernte Webbelege wiederverwendet',
'article.research.started': 'Artikelrecherche gestartet',
'article.research.results': 'SearXNG-Kandidaten gefunden',
'article.research.candidates': 'Quellenkandidaten bewertet',
'article.research.fetch.started': 'Webquelle wird geladen',
'article.research.fetch.completed': 'Webquelle als Volltext extrahiert',
'article.research.fetch.failed': 'Webquelle nicht nutzbar',
'article.research.evidence.accepted': 'Webquelle als Beleg akzeptiert',
'article.research.evidence.rejected': 'Webquelle fachlich verworfen',
'article.research.ingested': 'Geprüfte Artikelquellen verknüpft',
'article.research.learned': 'Webbelege semantisch gelernt',
'article.research.completed': 'Artikelrecherche abgeschlossen',
'article.research.failed': 'Artikelrecherche fehlgeschlagen',
'article.draft.started': 'KB-Entwurf wird geschrieben',
'article.draft.rejected': 'KB-Entwurf abgelehnt',
'article.created': 'KB-Entwurf erstellt',
'article.duplicate': 'KB-Entwurf bereits vorhanden',
'article.skipped': 'Noch kein belastbarer Artikel',
'article.failed': 'Artikelsynthese fehlgeschlagen',
'agent.run': 'Agent-Lauf',
'glpi.kb.synced': 'GLPI-KB synchronisiert',
'glpi.kb.failed': 'GLPI-KB Fehler',
'persistence.flushed': 'Gebündelt gespeichert',
'persistence.failed': 'Speicherfehler',
'autonomous.research.scan.started': 'Autonome Wissenslückensuche',
'autonomous.research.scan.completed': 'Graphanalyse abgeschlossen',
'autonomous.research.scan.failed': 'Autonome Graphanalyse fehlgeschlagen',
'autonomous.research.task.queued': 'Rechercheaufgabe eingeplant',
'autonomous.research.task.started': 'Autonome Recherche gestartet',
'autonomous.research.task.completed': 'Wissen autonom angereichert',
'autonomous.research.task.failed': 'Autonome Recherche zurückgestellt',
'autonomous.research.task.cancelled': 'Rechercheaufgabe abgebrochen'
};
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?.priority !== undefined) meta.push(`${Math.round(Number(evt.metadata.priority) * 100)}% Priorität`);
if (evt.metadata?.task_id) meta.push(`Task ${String(evt.metadata.task_id).slice(0, 8)}`);
if (evt.metadata?.evidence_count !== undefined) meta.push(`${Number(evt.metadata.evidence_count)} Belege`);
if (evt.metadata?.queries_executed !== undefined) meta.push(`${Number(evt.metadata.queries_executed)} Queries`);
if (evt.metadata?.pages_fetched !== undefined) meta.push(`${Number(evt.metadata.pages_fetched)} Volltexte`);
if (evt.metadata?.article_created === true) meta.push('KB-Entwurf erstellt');
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?.reused_count !== undefined) meta.push(`${Number(evt.metadata.reused_count)} gespeicherte Volltextbelege`);
if (evt.metadata?.result_count !== undefined) meta.push(`${Number(evt.metadata.result_count)} SearXNG-Treffer`);
if (evt.metadata?.research_round !== undefined || evt.metadata?.round !== undefined) meta.push(`Runde ${Number(evt.metadata.research_round ?? evt.metadata.round)}`);
if (evt.metadata?.question_count !== undefined) meta.push(`${Number(evt.metadata.question_count)} Forschungsfragen`);
if (evt.metadata?.candidate_count !== undefined) meta.push(`${Number(evt.metadata.candidate_count)} Kandidaten`);
if (evt.metadata?.eligible_count !== undefined) meta.push(`${Number(evt.metadata.eligible_count)} vorabrufgeeignet`);
if (evt.metadata?.strict_eligible_count !== undefined) meta.push(`${Number(evt.metadata.strict_eligible_count)} strikt geeignet`);
if (evt.metadata?.exploration_eligible_count !== undefined) meta.push(`${Number(evt.metadata.exploration_eligible_count)} explorativ geeignet`);
if (evt.metadata?.exploration_selected_count !== undefined) meta.push(`${Number(evt.metadata.exploration_selected_count)} Explorationsabrufe`);
if (evt.metadata?.selected_count !== undefined) meta.push(`${Number(evt.metadata.selected_count)} zum Volltextabruf`);
if (evt.metadata?.gate_rejected_count !== undefined) meta.push(`${Number(evt.metadata.gate_rejected_count)} am Gate verworfen`);
if (evt.metadata?.duplicate_skipped_count) meta.push(`${Number(evt.metadata.duplicate_skipped_count)} bereits geprüft`);
if (evt.metadata?.fetch_limit_skipped_count) meta.push(`${Number(evt.metadata.fetch_limit_skipped_count)} wegen Fetch-Limit zurückgestellt`);
if (evt.metadata?.fetched_count !== undefined) meta.push(`${Number(evt.metadata.fetched_count)} Volltexte`);
if (evt.metadata?.accepted_count !== undefined) meta.push(`${Number(evt.metadata.accepted_count)} Belege akzeptiert`);
if (evt.metadata?.rejected_count !== undefined) meta.push(`${Number(evt.metadata.rejected_count)} Quellen verworfen`);
if (evt.metadata?.remaining_critical_gaps !== undefined) {
const value = Array.isArray(evt.metadata.remaining_critical_gaps) ? evt.metadata.remaining_critical_gaps.length : Number(evt.metadata.remaining_critical_gaps);
meta.push(`${value} kritische Lücken offen`);
}
if (evt.metadata?.resolved_critical_gaps !== undefined) meta.push(`${Number(evt.metadata.resolved_critical_gaps)} kritische Lücken gelöst`);
if (evt.metadata?.characters !== undefined) meta.push(`${Number(evt.metadata.characters).toLocaleString('de-DE')} Zeichen Volltext`);
if (evt.metadata?.dimensions !== undefined) meta.push(`${Number(evt.metadata.dimensions)} Dimensionen`);
if (evt.metadata?.relevance !== undefined) meta.push(`${Math.round(Number(evt.metadata.relevance) * 100)}% relevant`);
if (evt.metadata?.source_quality) meta.push(String(evt.metadata.source_quality));
if (evt.metadata?.source_quality_score !== undefined) meta.push(`${Math.round(Number(evt.metadata.source_quality_score) * 100)}% Quellenqualität`);
const researchURL = evt.metadata?.final_url || evt.metadata?.result_url || '';
if (researchURL) {
try { meta.push(new URL(String(researchURL)).hostname); } catch {}
}
if (evt.metadata?.actionable === true) meta.push('konkrete Schritte');
if (evt.metadata?.language) meta.push(String(evt.metadata.language));
if (evt.metadata?.gap_id) meta.push(`Lücke ${String(evt.metadata.gap_id)}`);
if (Array.isArray(evt.metadata?.result_domains) && evt.metadata.result_domains.length) meta.push(evt.metadata.result_domains.slice(0, 3).join(' · '));
if (evt.metadata?.searxng_http_status) meta.push(`HTTP ${Number(evt.metadata.searxng_http_status)}`);
if (evt.metadata?.error_kind) meta.push(String(evt.metadata.error_kind));
if (evt.metadata?.searxng_content_type) meta.push(String(evt.metadata.searxng_content_type).split(';')[0]);
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?.relations_created !== undefined) meta.push(`${Number(evt.metadata.relations_created)} Relationen`);
if (evt.metadata?.articles_created !== undefined) meta.push(`${Number(evt.metadata.articles_created)} Artikel`);
if (evt.metadata?.articles_skipped !== undefined) meta.push(`${Number(evt.metadata.articles_skipped)} Artikel übersprungen`);
if (evt.metadata?.productive_sources !== undefined) meta.push(`${Number(evt.metadata.productive_sources)} produktive Quellen`);
if (evt.metadata?.ai_sources !== undefined) meta.push(`${Number(evt.metadata.ai_sources)} AI-Quellen`);
if (evt.metadata?.production_ratio !== undefined) meta.push(`${Math.round(Number(evt.metadata.production_ratio) * 100)}% Produktionswissen`);
if (evt.metadata?.generation_depth !== undefined) meta.push(`Tiefe ${Number(evt.metadata.generation_depth)}`);
if (evt.metadata?.action) meta.push(String(evt.metadata.action).toUpperCase());
if (evt.metadata?.article_type) meta.push(String(evt.metadata.article_type));
if (evt.metadata?.reason) meta.push(`Grund: ${String(evt.metadata.reason)}`);
if (evt.metadata?.field) meta.push(`Feld: ${String(evt.metadata.field)}`);
if (evt.metadata?.actual !== undefined && evt.metadata?.required !== undefined) meta.push(`${String(evt.metadata.actual)} / benötigt ${String(evt.metadata.required)}`);
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.metadata?.prefetch_minimum_relevance !== undefined) meta.push(`Vorabruf ab ${Math.round(Number(evt.metadata.prefetch_minimum_relevance) * 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.metadata?.assessment_reason && (evt.type === 'article.research.evidence.accepted' || evt.type === 'article.research.evidence.rejected')) {
message = `${message} · ${String(evt.metadata.assessment_reason)}`;
}
if ((evt.type === 'think.started' || evt.type === 'think.relation.created' || evt.type === 'think.rejected' || evt.type === 'research.started' || evt.type?.startsWith('article.')) && 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.`;
}
const candidateDecisions = Array.isArray(evt.metadata?.candidate_decisions) ? evt.metadata.candidate_decisions : [];
const candidateModeLabel = mode => ({strict: 'STRIKT', exploration: 'EXPLORATION', deferred: 'ZURÜCKGESTELLT', rejected: 'VERWORFEN', duplicate: 'BEREITS GEPRÜFT'}[String(mode)] || String(mode || '').toUpperCase());
const candidateLabels = candidateDecisions.map(candidate => {
const relevance = Math.round(Number(candidate?.relevance || 0) * 100);
const quality = Math.round(Number(candidate?.source_quality_score || 0) * 100);
const reason = Array.isArray(candidate?.reasons) && candidate.reasons.length ? ` · ${String(candidate.reasons[0])}` : '';
return `${candidateModeLabel(candidate?.mode)} · R ${relevance}% · Q ${quality}% · ${String(candidate?.title || candidate?.domain || 'Quelle')}${reason}`;
});
const sourceTitles = candidateLabels.length ? candidateLabels : Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles : Array.isArray(evt.metadata?.selected_titles) ? evt.metadata.selected_titles : evt.metadata?.result_title ? [evt.metadata.result_title] : [];
const sources = sourceTitles.map(String).slice(0, candidateLabels.length ? 8 : 4);
return {time, title, message, meta, query: eventQuery, regions, sources, error: evt.metadata?.error ? String(evt.metadata.error) : '', endpoint: evt.metadata?.searxng_base_url ? String(evt.metadata.searxng_base_url) : '', cls: evt.type?.includes('think') || evt.type?.startsWith('article.') ? (evt.type?.includes('research') ? 'research' : '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 = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.sources?.length ? `<div class="sources">${out.sources.map((source, index) => `<span><i>${index + 1}</i>${escapeHTML(source)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}${out.error ? `<div class="error-detail">${out.endpoint ? `${escapeHTML(out.endpoint)}\n` : ''}${escapeHTML(out.error)}</div>` : ''}`;
const log = $('activityLog');
log.prepend(item);
while (log.children.length > 18) log.removeChild(log.lastChild);
}
const stream = new EventSource('/api/stream');
stream.addEventListener('activity', e => {
try {
activate(JSON.parse(e.data));
setSystem('lebt', true);
} catch {}
});
stream.onerror = () => setSystem('verbindet', false);
stream.onopen = () => setSystem('lebt', true);
$('viewNeural').addEventListener('click', () => applyViewMode('neural', true));
$('viewHoneycomb').addEventListener('click', () => applyViewMode('honeycomb', true));
$('viewConstellation').addEventListener('click', () => applyViewMode('constellation', true));
$('toggleLearning').addEventListener('click', async e => {
const button = e.currentTarget;
button.disabled = true;
state.runtimeSettings.learning_enabled = !state.runtimeSettings.learning_enabled;
try {
await persistRuntimeSettings();
if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) setVisualMode('living');
} catch (err) {
state.runtimeSettings.learning_enabled = !state.runtimeSettings.learning_enabled;
addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Learning konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()});
} finally {
button.disabled = false;
syncRuntimeControls();
}
});
$('toggleThinking').addEventListener('click', async e => {
const button = e.currentTarget;
button.disabled = true;
state.runtimeSettings.thinking_enabled = !state.runtimeSettings.thinking_enabled;
try {
await persistRuntimeSettings();
if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) setVisualMode('living');
} catch (err) {
state.runtimeSettings.thinking_enabled = !state.runtimeSettings.thinking_enabled;
addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Thinking konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()});
} finally {
button.disabled = false;
syncRuntimeControls();
}
});
$('testResearch')?.addEventListener('click', async e => {
const button = e.currentTarget;
const feedback = $('researchTestFeedback');
const query = String($('researchTestQuery')?.value || '').trim();
button.disabled = true;
if (feedback) feedback.textContent = 'Test läuft …';
try {
const result = await api('/api/research/test', {method: 'POST', body: JSON.stringify({query, limit: 4})});
renderResearchStatus(result.diagnostic || {});
if (feedback) feedback.textContent = `${Number(result.diagnostic?.result_count || result.results?.length || 0)} Treffer empfangen`;
} catch (err) {
renderResearchStatus(err.data?.diagnostic || {configured: true, ok: false, checked_at: new Date().toISOString(), error: err.message});
if (feedback) feedback.textContent = err.message;
} finally {
button.disabled = false;
await loadStatus();
}
});
$('openSettings').addEventListener('click', openSettingsPanel);
$('closeSettings').addEventListener('click', closeSettingsPanel);
$('settingsBackdrop').addEventListener('click', closeSettingsPanel);
$('sourceSearch').addEventListener('input', e => renderSourceFilters(e.currentTarget.value));
$('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; });
$('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; });
$('settingsProcessingPrecise')?.addEventListener('click', () => { if (state.settingsDraft) { state.settingsDraft.processing_mode = 'precise'; syncRuntimeControls(); } });
$('settingsProcessingClustered')?.addEventListener('click', () => { if (state.settingsDraft) { state.settingsDraft.processing_mode = 'clustered'; syncRuntimeControls(); } });
$('settingsLowPower').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.low_power_mode = e.currentTarget.checked; });
$('settingsAutonomousResearch')?.addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.autonomous_research_enabled = e.currentTarget.checked; });
$('settingsAutonomousIdleOnly')?.addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.autonomous_research_idle_only = e.currentTarget.checked; });
$('settingsAutonomousMinPriority')?.addEventListener('input', e => { if (state.settingsDraft) state.settingsDraft.autonomous_research_min_priority = Math.max(0, Math.min(1, Number(e.currentTarget.value) || 0)); });
$('settingsAutonomousMaxPerDay')?.addEventListener('input', e => { if (state.settingsDraft) state.settingsDraft.autonomous_research_max_tasks_per_day = Math.max(1, Math.min(500, Math.trunc(Number(e.currentTarget.value) || 1))); });
$('settingsAutonomousPerCycle')?.addEventListener('input', e => { if (state.settingsDraft) state.settingsDraft.autonomous_research_tasks_per_cycle = Math.max(1, Math.min(8, Math.trunc(Number(e.currentTarget.value) || 1))); });
$('settingsMaxDisplayNodes').addEventListener('input', e => {
if (!state.settingsDraft) return;
state.settingsDraft.max_display_nodes = Math.max(0, Math.min(500000, Math.trunc(Number(e.currentTarget.value) || 0)));
updateDisplayLimitHint(state.settingsDraft.max_display_nodes);
});
function selectSettingsView(mode) {
if (state.settingsDraft) state.settingsDraft.view_mode = mode;
for (const candidate of ['Neural', 'Honeycomb', 'Constellation']) {
$(`settingsView${candidate}`).classList.toggle('active', candidate.toLowerCase() === mode);
}
}
$('settingsViewNeural').addEventListener('click', () => selectSettingsView('neural'));
$('settingsViewHoneycomb').addEventListener('click', () => selectSettingsView('honeycomb'));
$('settingsViewConstellation').addEventListener('click', () => selectSettingsView('constellation'));
$('settingsPanel').addEventListener('change', e => {
const input = e.target.closest('[data-source-filter]');
if (!input || !state.settingsDraft) return;
const key = input.dataset.sourceFilter;
const property = `${key}_sources`;
const selected = new Set(normalizedSourceValues(state.settingsDraft[property] || []));
if (input.checked) selected.add(input.value); else selected.delete(input.value);
state.settingsDraft[property] = [...selected];
renderFilterScopeSummary();
});
document.querySelectorAll('[data-clear-source-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearSourceFilter}_sources`] = [];
renderSourceFilters($('sourceSearch').value);
renderFilterScopeSummary();
}));
document.querySelectorAll('[data-node-limit]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
const value = Math.max(0, Math.min(500000, Math.trunc(Number(button.dataset.nodeLimit) || 0)));
state.settingsDraft.max_display_nodes = value;
$('settingsMaxDisplayNodes').value = String(value);
updateDisplayLimitHint(value);
}));
$('saveSettings').addEventListener('click', async e => {
if (!state.settingsDraft) return;
const button = e.currentTarget;
const feedback = $('settingsFeedback');
button.disabled = true;
feedback.textContent = 'Einstellungen werden übernommen …';
try {
state.settingsDraft.learning_enabled = $('settingsLearning').checked;
state.settingsDraft.thinking_enabled = $('settingsThinking').checked;
state.settingsDraft.low_power_mode = $('settingsLowPower').checked;
state.settingsDraft.processing_mode = state.settingsDraft.processing_mode === 'clustered' ? 'clustered' : 'precise';
state.settingsDraft.max_display_nodes = Math.max(0, Math.min(500000, Math.trunc(Number($('settingsMaxDisplayNodes').value) || 0)));
state.settingsDraft.autonomous_research_enabled = Boolean($('settingsAutonomousResearch')?.checked);
state.settingsDraft.autonomous_research_idle_only = $('settingsAutonomousIdleOnly')?.checked !== false;
state.settingsDraft.autonomous_research_min_priority = Math.max(0, Math.min(1, Number($('settingsAutonomousMinPriority')?.value || 0.65)));
state.settingsDraft.autonomous_research_max_tasks_per_day = Math.max(1, Math.min(500, Math.trunc(Number($('settingsAutonomousMaxPerDay')?.value || 12))));
state.settingsDraft.autonomous_research_tasks_per_cycle = Math.max(1, Math.min(8, Math.trunc(Number($('settingsAutonomousPerCycle')?.value || 1))));
await persistRuntimeSettings(state.settingsDraft);
feedback.textContent = 'Aktiv · Speicherung erfolgt gebündelt mit dem nächsten Flush.';
setTimeout(closeSettingsPanel, 550);
} catch (err) {
feedback.textContent = `Fehler: ${err.message}`;
} finally {
button.disabled = false;
}
});
$('queueAutonomousResearch')?.addEventListener('click', async e => {
const button = e.currentTarget;
const feedback = $('autonomousResearchFeedback');
const topic = String($('autonomousResearchTopic')?.value || '').trim();
if (!topic) {
if (feedback) feedback.textContent = 'Bitte ein Thema oder eine konkrete Wissensfrage eingeben.';
return;
}
button.disabled = true;
if (feedback) { feedback.dataset.manual = '1'; feedback.textContent = 'Rechercheaufgabe wird eingeplant …'; }
try {
const result = await api('/api/research/tasks', {method: 'POST', body: JSON.stringify({topic, question: topic, priority: 0.9, requested_by: 'webui', reason: 'manual_webui'})});
if (feedback) feedback.textContent = result.created ? 'Aufgabe wurde asynchron in die Research Queue gelegt.' : 'Eine gleichartige Aufgabe befindet sich bereits in der Cooldown- oder Arbeitsphase.';
if ($('autonomousResearchTopic')) $('autonomousResearchTopic').value = '';
await loadAutonomousResearchTasks();
} catch (err) {
if (feedback) feedback.textContent = `Fehler: ${err.message}`;
} finally {
button.disabled = false;
setTimeout(() => { if (feedback) delete feedback.dataset.manual; }, 2500);
}
});
$('scanAutonomousResearch')?.addEventListener('click', async e => {
const button = e.currentTarget;
const feedback = $('autonomousResearchFeedback');
button.disabled = true;
if (feedback) { feedback.dataset.manual = '1'; feedback.textContent = 'Graphsignale werden asynchron bewertet …'; }
try {
await api('/api/research/autonomous/scan', {method: 'POST', body: '{}'});
if (feedback) feedback.textContent = 'Opportunity-Scan wurde eingeplant. Ergebnisse erscheinen im Aktivitätsfeed und in der Queue.';
} catch (err) {
if (feedback) feedback.textContent = `Fehler: ${err.message}`;
} finally {
button.disabled = false;
setTimeout(() => { if (feedback) delete feedback.dataset.manual; }, 2500);
}
});
$('runAutonomousResearch')?.addEventListener('click', async e => {
const button = e.currentTarget;
const feedback = $('autonomousResearchFeedback');
button.disabled = true;
try {
await api('/api/research/autonomous/run', {method: 'POST', body: '{}'});
if (feedback) { feedback.dataset.manual = '1'; feedback.textContent = 'Queue wurde geweckt. Der Worker wartet weiterhin auf Ollama-Kapazität und Leerlauf.'; }
} catch (err) {
if (feedback) feedback.textContent = `Fehler: ${err.message}`;
} finally {
button.disabled = false;
setTimeout(() => { if (feedback) delete feedback.dataset.manual; }, 2500);
}
});
$('autonomousResearchQueue')?.addEventListener('click', async e => {
const button = e.target.closest('[data-cancel-research-task]');
if (!button) return;
button.disabled = true;
try {
await api(`/api/research/tasks/${encodeURIComponent(button.dataset.cancelResearchTask)}/cancel`, {method: 'POST', body: '{}'});
await loadAutonomousResearchTasks();
} catch (err) {
addLog({type: 'autonomous.research.task.cancel.failed', source: 'ui', message: err.message, timestamp: new Date().toISOString()});
} finally {
button.disabled = false;
}
});
$('toggleEco').addEventListener('click', async e => {
const button = e.currentTarget;
button.disabled = true;
const previous = Boolean(state.runtimeSettings.low_power_mode);
state.runtimeSettings.low_power_mode = !previous;
applyPerformanceMode(state.runtimeSettings.low_power_mode, true);
try {
await persistRuntimeSettings();
} catch (err) {
state.runtimeSettings.low_power_mode = previous;
applyPerformanceMode(previous, true);
addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Eco-Modus konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()});
} finally {
button.disabled = false;
syncRuntimeControls();
}
});
$('toggleLabels').addEventListener('click', e => {
state.labels = !state.labels;
e.currentTarget.classList.toggle('active', state.labels);
});
$('toggleEdges').addEventListener('click', e => {
state.edgesVisible = !state.edgesVisible;
e.currentTarget.classList.toggle('active', state.edgesVisible);
});
$('toggleRotate').addEventListener('click', e => {
state.autoRotate = !state.autoRotate;
e.currentTarget.classList.toggle('active', state.autoRotate);
});
$('toggleCortex').addEventListener('click', e => {
state.cortexVisible = !state.cortexVisible;
e.currentTarget.classList.toggle('active', state.cortexVisible);
});
$('toggleLOD').addEventListener('click', e => {
state.lodEnabled = !state.lodEnabled;
e.currentTarget.classList.toggle('active', state.lodEnabled);
state.lodDirty = true;
rebuildRenderGraph(performance.now(), true);
});
$('resetView').addEventListener('click', () => {
state.yaw = 0.18;
state.pitch = -0.12;
state.zoom = 1.02;
state.zoomTarget = 1.02;
state.lodDirty = true;
});
$('clearLog').addEventListener('click', () => {$('activityLog').innerHTML = '';});
$('enrichNow').addEventListener('click', async e => {
const button = e.currentTarget;
button.disabled = true;
button.classList.add('running');
const small = button.querySelector('small');
if (small) small.textContent = 'STARTET';
try {
await api('/api/enrich?async=1', {method: 'POST', body: '{}'});
await loadStatus();
} catch (err) {
addLog({type: 'think.failed', source: 'ui', phase: 'manual', message: `Manueller AI-THINK-Start fehlgeschlagen: ${err.message}`, timestamp: new Date().toISOString(), metadata: {trigger: 'manual'}});
button.disabled = false;
button.classList.remove('running');
if (small) small.textContent = 'STARTEN';
}
});
canvas.addEventListener('pointerdown', e => {
state.dragging = true;
state.moved = false;
state.lastX = e.clientX;
state.lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
if (state.dragging) {
const dx = e.clientX - state.lastX;
const dy = e.clientY - state.lastY;
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) state.moved = true;
state.yaw += dx * 0.006;
state.pitch = Math.max(-1, Math.min(1, state.pitch + dy * 0.005));
state.lastX = e.clientX;
state.lastY = e.clientY;
return;
}
let best = null, dist = 14;
for (const n of state.projected) {
if (!n.screen || n._fadeOutStart) continue;
const d = Math.hypot(n.screen.x - e.clientX, n.screen.y - e.clientY);
if (d < dist) {
dist = d;
best = n;
}
}
state.hover = best;
const tip = $('tooltip');
if (best) {
tip.classList.remove('hidden');
if (best.kind === 'supernode') {
const level = best.lodLevel === 2 ? 'Themenwolke' : 'lokale Wissensgruppe';
tip.innerHTML = `<strong>${escapeHTML(best.label)}</strong><small>${level} · ${best.memberCount.toLocaleString('de-DE')} Elemente<br>${best.internalEdgeCount.toLocaleString('de-DE')} interne · ${best.externalEdgeCount.toLocaleString('de-DE')} externe Edges<br>Klicken zum Auflösen</small>`;
} else {
const degree = (state.adjacency.get(best.id) || []).length;
tip.innerHTML = `<strong>${escapeHTML(best.label)}</strong><small>${escapeHTML((best.categories || []).slice(0, 3).join(' · ') || best.kind || 'Knoten')}<br>${degree} Verbindungen · ${escapeHTML(best.status || 'aktiv')}</small>`;
}
tip.style.left = Math.min(state.width - 280, e.clientX + 14) + 'px';
tip.style.top = Math.min(state.height - 86, e.clientY + 14) + 'px';
} else {
tip.classList.add('hidden');
}
});
canvas.addEventListener('pointerup', () => {
if (!state.moved && state.hover) {
if (state.hover.kind === 'supernode') {
openLODGroup(state.hover);
state.renderActive.set(state.hover.id, 1.1);
} else {
state.selected = state.hover;
state.active.set(state.hover.id, 1.2);
revealLODNodes([state.hover.id], LOD_CONFIG.revealNodeMS);
rebuildRenderGraph(performance.now(), true);
}
}
state.dragging = false;
});
canvas.addEventListener('pointercancel', () => state.dragging = false);
canvas.addEventListener('wheel', e => {
e.preventDefault();
state.zoom = Math.max(0.55, Math.min(2.4, state.zoom * Math.exp(-e.deltaY * 0.001)));
state.zoomTarget = state.zoom;
state.lodDirty = true;
}, {passive: false});
function escapeHTML(v) {
return String(v ?? '').replace(/[&<>'"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[c]));
}
(async () => {
await loadRuntimeConfiguration();
await loadGraph();
await loadStatus();
await loadAutonomousResearchTasks().catch(() => {});
})();
setInterval(loadGraph, 30000);
setInterval(loadStatus, 3000);
setInterval(() => loadAutonomousResearchTasks().catch(() => {}), 7000);
})();