All checks were successful
release-tag / release-image (push) Successful in 2m32s
3423 lines
174 KiB
JavaScript
3423 lines
174 KiB
JavaScript
(() => {
|
||
'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, speed_mode: false, speed_cpu_tasks: 8, speed_gpu_tasks: 4, 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 ($('settingsSpeedMode')) $('settingsSpeedMode').checked = Boolean(panelSettings.speed_mode);
|
||
if ($('settingsSpeedCPU')) $('settingsSpeedCPU').value = String(Math.max(1, Math.min(256, Number(panelSettings.speed_cpu_tasks || 1))));
|
||
if ($('settingsSpeedGPU')) $('settingsSpeedGPU').value = String(Math.max(1, Math.min(64, Number(panelSettings.speed_gpu_tasks || 1))));
|
||
if ($('toggleSpeed')) $('toggleSpeed').classList.toggle('active', Boolean(panelSettings.speed_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 reduziert den Vollscan. Verwandte Relationen werden gebündelt; Artikel arbeiten intern/Gemma-first und nutzen SearXNG nur bei Aktualitäts- oder Evidenzbedarf. Webmaterial wird erst nach Reviewer-Grounding in den Graphen übernommen.' : '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) && !Boolean(settings.speed_mode),
|
||
speed_mode: Boolean(settings.speed_mode),
|
||
speed_cpu_tasks: Math.max(1, Math.min(256, Math.trunc(Number(settings.speed_cpu_tasks) || 1))),
|
||
speed_gpu_tasks: Math.max(1, Math.min(64, Math.trunc(Number(settings.speed_gpu_tasks) || 1))),
|
||
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 {
|
||
const scan = status.last_scan || {};
|
||
const candidates = Number(scan.candidate_count || 0);
|
||
const created = Number(scan.created || 0);
|
||
const rejected = Object.entries(scan.rejection_counts || {}).filter(([key]) => key !== 'accepted').reduce((sum, [, value]) => sum + Number(value || 0), 0);
|
||
const scanSummary = scan.completed ? ` · letzter Scan: ${candidates} Kandidaten, ${created} Aufgaben, ${rejected} verworfen` : '';
|
||
feedback.textContent = `${queued} wartende Aufgaben · Intervall ${status.interval || '–'} · Cooldown ${status.cooldown || '–'}${scanSummary}.`;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (status.article_effective_research_strategy) {
|
||
diagnostics.push(`Webstrategie ${status.article_effective_research_strategy}`);
|
||
}
|
||
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.sources.autonomous_seeds', 'article.research.round.started', 'article.research.round.completed', 'article.research.reused', 'article.research.strategy', 'article.research.author_requested', 'article.research.material.stored', 'article.research.grounded.materialized', 'article.cluster.deferred', 'article.cluster.started', '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.cpu_quality.completed',
|
||
'article.cpu_quality.revision',
|
||
'article.type.reconsidered', 'article.type.reconsideration.failed',
|
||
'article.review.completed',
|
||
'think.research.query.rebuilt', 'think.research.insufficient',
|
||
'vector.graph.rebuilt', 'vector.graph.maintenance.started', 'vector.graph.maintenance.completed', 'vector.graph.maintenance.failed', 'article.duplicate', 'article.skipped', 'article.failed', 'article.fingerprint.failed', 'vector.graph.agent.waiting', 'vector.graph.agent.queued', 'vector.graph.agent.completed', '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.merged', 'autonomous.research.queue.consolidated', 'autonomous.research.task.started', 'autonomous.research.task.completed', 'autonomous.research.article.focused', '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.strategy': 'Rechercheweg gewählt',
|
||
'article.research.author_requested': 'Autor fordert gezielte Webbelege an',
|
||
'article.research.material.stored': 'Webmaterial nur im Evidence-Store',
|
||
'article.research.grounded.materialized': 'Reviewer-Belege im Graph materialisiert',
|
||
'article.cluster.deferred': 'Relation für Artikelcluster vorgemerkt',
|
||
'article.cluster.started': 'Gebündelter Artikelauftrag gestartet',
|
||
'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',
|
||
'article.fingerprint.failed': 'Artikel-Wiederholschutz konnte nicht gespeichert werden',
|
||
'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.article.focused': 'Artikelziel auf Einzelproblem fokussiert',
|
||
'article.sources.autonomous_seeds': 'Opportunity-Quellen als Artikel-Seeds fixiert',
|
||
'vector.graph.agent.waiting': 'Warte auf Compute-Agent beim Bootstrap',
|
||
'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; if (e.currentTarget.checked) state.settingsDraft.speed_mode = false; syncRuntimeControls(); } });
|
||
$('settingsSpeedMode')?.addEventListener('change', e => { if (state.settingsDraft) { state.settingsDraft.speed_mode = e.currentTarget.checked; if (e.currentTarget.checked) state.settingsDraft.low_power_mode = false; syncRuntimeControls(); } });
|
||
$('settingsSpeedCPU')?.addEventListener('input', e => { if (state.settingsDraft) state.settingsDraft.speed_cpu_tasks = Math.max(1, Math.min(256, Math.trunc(Number(e.currentTarget.value) || 1))); });
|
||
$('settingsSpeedGPU')?.addEventListener('input', e => { if (state.settingsDraft) state.settingsDraft.speed_gpu_tasks = Math.max(1, Math.min(64, Math.trunc(Number(e.currentTarget.value) || 1))); });
|
||
$('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 && !$('settingsSpeedMode')?.checked;
|
||
state.settingsDraft.speed_mode = Boolean($('settingsSpeedMode')?.checked);
|
||
state.settingsDraft.speed_cpu_tasks = Math.max(1, Math.min(256, Math.trunc(Number($('settingsSpeedCPU')?.value || 1))));
|
||
state.settingsDraft.speed_gpu_tasks = Math.max(1, Math.min(64, Math.trunc(Number($('settingsSpeedGPU')?.value || 1))));
|
||
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;
|
||
if (state.runtimeSettings.low_power_mode) state.runtimeSettings.speed_mode = false;
|
||
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();
|
||
}
|
||
});
|
||
|
||
$('toggleSpeed')?.addEventListener('click', async e => {
|
||
const button = e.currentTarget;
|
||
button.disabled = true;
|
||
const previous = Boolean(state.runtimeSettings.speed_mode);
|
||
state.runtimeSettings.speed_mode = !previous;
|
||
if (state.runtimeSettings.speed_mode) {
|
||
state.runtimeSettings.low_power_mode = false;
|
||
applyPerformanceMode(false, true);
|
||
}
|
||
try {
|
||
await persistRuntimeSettings();
|
||
} catch (err) {
|
||
state.runtimeSettings.speed_mode = previous;
|
||
addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Speed-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 => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[c]));
|
||
}
|
||
|
||
(async () => {
|
||
await loadRuntimeConfiguration();
|
||
await loadGraph();
|
||
await loadStatus();
|
||
await loadAutonomousResearchTasks().catch(() => {});
|
||
})();
|
||
setInterval(loadGraph, 30000);
|
||
setInterval(loadStatus, 3000);
|
||
setInterval(() => loadAutonomousResearchTasks().catch(() => {}), 7000);
|
||
})();
|