This commit is contained in:
2026-08-04 04:40:08 +02:00
parent d6d7459aab
commit 4355f6bfd6
9 changed files with 499 additions and 82 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
knowledge
/.env
/staging

View File

@@ -23,3 +23,17 @@ KB staging/ ──────rw───┘ │
## Datenhoheit
Produktive JSON-Dateien werden niemals verändert. Der einzige schreibende Fremdpfad ist das explizit konfigurierte Staging-Verzeichnis. Alle Dateien werden atomar über Temporärdatei und Rename geschrieben.
## Hierarchische Render-Schicht
Der persistierte Wissensgraph bleibt unverändert vollständig. Das Frontend leitet daraus eine temporäre, hierarchische Render-Schicht ab:
```text
Full Graph
└── Cortex-Region
└── Themenwolke (LOD 2)
└── lokale Gruppe (LOD 1)
└── einzelner Wissensknoten (LOD 0)
```
Die sichtbare Ebene wird durch Zoom, Fokus und Aktivitätszeiten bestimmt. Außenkanten werden nach sichtbaren Endpunkten, Relationstyp, Richtung, Herkunft und Status aggregiert. Eine Aktivierung öffnet ausschließlich den betroffenen Pfad der Hierarchie; nach Ablauf der Offenhaltezeit wird dieser wieder verdichtet. Die KI-, Retrieval- und Persistenzlogik arbeitet weiterhin ausschließlich mit den echten Node- und Edge-IDs.

View File

@@ -29,6 +29,20 @@ Die Themenstruktur wird nicht nur aus dem ersten Kategorie-Feld abgeleitet. Kate
Der Aktivitätsfeed zeigt nur relevante Ereignisse und ergänzt sofern vorhanden Cortex-Bereich, Trefferzahl, verwendete Quellen, Laufzeit, semantische Nähe, Relationstyp, Konfidenz, Recherchequellen, Ticket-ID und Staging-Pfad. Der Statusblock zeigt außerdem, ob der autonome Worker läuft, wartet, keine Kandidaten findet oder Ollama nicht erreicht. Über den wieder eingeblendeten **AI-THINK**-Button kann jederzeit ein manueller Zyklus angestoßen werden.
### Hierarchisches Semantic-LOD
Die Visualisierung rendert nicht mehr permanent jeden Knoten und jede Kante. Räumlich nahe, inaktive Elemente derselben Cortex-Region werden in zwei Stufen verdichtet:
- **Themenwolke (Level 2):** größere ruhende Bereiche;
- **lokale Wissensgruppe (Level 1):** kleinere Gruppen innerhalb einer Themenwolke;
- **Einzel-Node (Level 0):** konkrete Wissenseinträge bei Aktivität oder starkem Zoom.
Alle Originaldaten bleiben im Browser erhalten. Nur der abgeleitete `renderGraph` wird reduziert. Außenkanten einer Gruppe werden nach Quellgruppe, Zielgruppe, Relationstyp, Richtung, Herkunft und Status dedupliziert. `edgeCount` und `weightSum` bleiben an der aggregierten Kante erhalten; interne Kanten werden am Supernode gezählt.
Wird ein enthaltenes Wissenselement durch Agent, Knowledgebase, AI-THINK oder Recherche aktiviert, öffnet sich zunächst seine Themenwolke in engere Gruppen und anschließend die betroffene lokale Gruppe in Einzel-Nodes. Nach 30 bis 42 Sekunden ohne erneute Aktivität fällt der Bereich wieder zusammen. Zoom-Hysterese verhindert Flackern. Der **LOD**-Schalter kann die Verdichtung zu Diagnosezwecken deaktivieren; die Kennzahl **Render** im Header zeigt die tatsächlich pro Frame gezeichneten Nodes.
Die Grenzwerte stehen am Anfang von `internal/web/static/app.js` in `LOD_CONFIG` (`localDistance`, `coarseDistance`, Gruppengrößen und Offenhaltezeiten).
## Schutz der Basisprojekte
Das Brain bekommt nur:

View File

@@ -1,24 +1,24 @@
6e7da366f9f451117f3d80acce5c94e551b7838eaa063ee887aeea51ab148197 ./.env.example
9f81f9f707d706c92059977b7c5a2ec68ed15c39dcd6d2d57aadee1cbb1e3bb7 ./ARCHITECTURE.md
b3704d36a5da4bca7faab55ad945a026e93843d169be7c942a3ebe66cb241e2e ./.env.example
cd2a385faa5059dc1c756533bce56666a124272682b738629831f042c7a0e0fb ./ARCHITECTURE.md
99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile
adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile
23f3748f6adef25034c32832272a5c0d03700aed0619742e6a8c450b84f95a07 ./README.md
221c2e42a6234fa3bae31eb71745182489f466fcedbc8f3d3e9d918ce6d35adf ./README.md
cf53db59fb1b3057019d54166bfbb0c95fbf84cb5032cad3ebea30af3aab69b3 ./cmd/brain/main.go
f854ec4e9b1a5190a294529b58759c0e8535a1715bf12bb24f5b588af9b98b72 ./data/graph-state.json
418310e2660f34890c4be97d6e4a0873cce57fe5b8eb6bfdff25bcd2d5faa7bf ./data/graph-state.json
2404066ac6c893852a3f2bba00b33883b7e0eeb542448caf733fff4862715ed0 ./deployment/README.md
d2c5ea4134a3df1ffa9c6409603f2fcf7bc30d16e3ac66e46328d059de5b5eff ./deployment/docker-compose.full.yml
b8c9c01bdf731530252bbc109fd898d3c76b6e44ec6d589ffd0c87a60111ea04 ./docker-compose.yml
2f3d69501091987783f8fd5e4d677d1191fd95fc435b12e907b0bcbf53d540aa ./deployment/docker-compose.full.yml
63b043e874d4cb8130d7bc5a8eca58cfb4cdd1069ff18c20a568ada0d373f9cd ./docker-compose.yml
9126f8bca1144abfc77747063b2c8f31acf12839a75e060b98369e10a00061ca ./go.mod
bf239391e61040b00d2f49d805b0d49a44bd3679f3c53849dfbc5e3b5a3fcc7a ./integrations/agent/README.md
a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations/agent/glpi-ai-agent-neural-brain.patch
73ab7c49600cdaa4795e76c50e66ce919dec171b3a07f2d16ff6f45ce5f73365 ./integrations/knowledgebase/README.md
36666043ebf4139e13610e5fcd0b0c6f45e4e6a53fed33ec47d03a66878e7b08 ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
92f34f0f6535daffbbd405c5c0957b5367189420dc5b3ecbc505435534d8a464 ./internal/config/config.go
862b2eb57019d87f2221698341faf180820062fb309bdc6047129f25e902ed74 ./internal/engine/engine.go
9fa32035e4a606e69597329f78cbe6cadcaa47eec50d77299841ef049725c6a6 ./internal/engine/engine_test.go
0bf06e95cfafc65862f873a648240b192e158b814ffdd24c30d353ceb1042f82 ./internal/graph/store.go
2a7a62411c1a6eb70440ac13ce6bc9eef10914ea62ebd270bfc6e8444c036250 ./internal/graph/store_test.go
094c75a5335170e300d9c88ad183db40fa54cc0070dad3f0fdbb62c719adc1e8 ./internal/config/config.go
686a2d9b0eb741e7ec163883e5e407e2b629e13b7b0f420158b5a71608345c0a ./internal/engine/engine.go
55af6324acec36d5a4ce9e6c48ade1dcba9a4acbd2eda00a572ab71ca576bfea ./internal/engine/engine_test.go
64f896f3421ffc41836e0dce7fdcf8beb639258960fee7df07552539c0db2038 ./internal/graph/store.go
5c32a4e4fca939165a2fa7c0f380fdf79cfab7cc65924e21fe7b9ac5e85a2b6d ./internal/graph/store_test.go
4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea ./internal/ingest/agent.go
fe0a813efe140fdc8961015cb2a493928c97e456a80c2d8cf2ddbe7c6e335018 ./internal/ingest/knowledge.go
5ed96aec4c3d2599cb9af3f6e951533271dc2b9a4826667482da814f9b450da1 ./internal/ingest/knowledge_test.go
@@ -26,9 +26,9 @@ fb1af93afeb4ebc89cad95badeaee9a0cc3a236b9761e15a41a7b4b2c7192d00 ./internal/mod
2ea6284f6aa2c23c6f6583ba66647ae8f48432527746b99898bc567c1be64a52 ./internal/ollama/client.go
2eb686cfc9016b9b0cf15e5c342f693ec9b60c454bc4814c1c9583f6d5b5b806 ./internal/research/searxng.go
6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go
0f54fd249ef02616378b3630951eee4526f69593e54911058271c9eeb076874b ./internal/web/server.go
b5b46e461473e21c5bb5e1cd014c6cd1a1db4c0b734a52d93a47ea4bb73f9e7d ./internal/web/static/app.css
28c3461026cfdac5e0c4b08c92ede18c3ae0427ba4d9ad1193daf60c9974f9f3 ./internal/web/static/app.js
acbc97be2fa1d495270751a4a7b2ef0a58a9c7301e82a3cac901cda0f78f7600 ./internal/web/static/index.html
c81571f39dfa36c540c3f9b55ac05db90ffc85dd8ccf0da5fecd454a7f65b19d ./neural-brain
dda84763f509405c39cb533ee1c1f2bbeed48718cd7de9cbb66dd1adc5582e2c ./internal/web/server.go
644105a4921f0394bc0ab3cc4c8658053e456e593acf7573255bd867d6cde8dd ./internal/web/static/app.css
c310356d4bfe2c02a7bf078140c9c49febf8a7c4f9ab2dfacd1016e918953b0d ./internal/web/static/app.js
02007a3143603cda56794e36613f9a3cfc3cdbaf66279af0b3e4b9b5e8f4cbc2 ./internal/web/static/index.html
fcc7348cf65c04d023b0d0c1fa391e7628ae326d394e44c6fde9bb8eb6f665b5 ./neural-brain
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png

File diff suppressed because one or more lines are too long

View File

@@ -29,3 +29,4 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-grad
/* AI-THINK-Steuerung und echter Backend-Status */
.autonomy-status{display:flex;align-items:center;gap:8px;margin:-1px 0 10px;padding:8px 9px;border:1px solid rgba(82,231,255,.12);border-radius:10px;background:rgba(82,231,255,.035);color:#8fa9ba;font-size:10px;line-height:1.35}.autonomy-status i{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.autonomy-status.running{border-color:rgba(255,180,82,.28);background:rgba(255,180,82,.06);color:#ffd9a7}.autonomy-status.running i{background:var(--amber);box-shadow:0 0 12px var(--amber);animation:pulse .72s infinite}.autonomy-status.offline{border-color:rgba(255,95,136,.22);background:rgba(255,95,136,.05);color:#ffb0c5}.autonomy-status.offline i{background:var(--red);box-shadow:0 0 10px var(--red)}.autonomy-status.waiting{color:#a8bdca}.autonomy-status.waiting i{background:var(--blue);box-shadow:0 0 10px var(--blue)}
.dock .think-action{display:flex;align-items:center;gap:7px;border-color:rgba(255,180,82,.22);background:rgba(255,180,82,.07);color:var(--amber)}.dock .think-action small{font-size:8px;letter-spacing:.11em;opacity:.68}.dock .think-action:hover{background:rgba(255,180,82,.14);box-shadow:0 0 22px rgba(255,180,82,.08)}.dock .think-action.running{border-color:rgba(255,180,82,.48);background:rgba(255,180,82,.13);box-shadow:0 0 24px rgba(255,180,82,.11)}.dock .think-action:disabled{cursor:wait;opacity:.72}
.metrics span[title]{cursor:help}.dock #toggleLOD.active{color:var(--green);border-color:rgba(93,255,189,.22);background:rgba(93,255,189,.065)}

View File

@@ -17,6 +17,15 @@
processing: {label: 'PROCESSING', detail: 'Agent- und Wissenssuche', className: 'processing', color: [183, 117, 255], duration: 7800},
learning: {label: 'LEARNING', detail: 'indexiert bei stabiler Ansicht', className: 'learning', color: [75, 123, 255], duration: 6500}
};
const LOD_CONFIG = {
localDistance: 0.085,
coarseDistance: 0.23,
localMaxMembers: 24,
coarseMaxChildren: 18,
revealNodeMS: 30000,
revealParentMS: 42000,
rebuildThrottleMS: 120
};
const state = {
nodes: [], edges: [], clusters: [], clusterByKey: new Map(), nodeById: new Map(), edgeById: new Map(), adjacency: new Map(),
@@ -27,7 +36,11 @@
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
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}
};
function resize() {
@@ -67,6 +80,8 @@
state.adjacency.get(e.target).push(e);
}
buildLayout(old, oldClusters);
buildLODHierarchy();
rebuildRenderGraph(performance.now(), true);
$('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE');
$('edgeCount').textContent = state.edges.length.toLocaleString('de-DE');
} catch {
@@ -411,6 +426,272 @@
}
}
function splitSpatial(items, maxItems) {
if (items.length <= maxItems) return [items];
const ranges = ['x', 'y', 'z'].map(axis => {
let min = Infinity, max = -Infinity;
for (const item of items) {
min = Math.min(min, item[axis]);
max = Math.max(max, item[axis]);
}
return {axis, range: max - min};
}).sort((a, b) => b.range - a.range);
const axis = ranges[0].axis;
const sorted = [...items].sort((a, b) => a[axis] - b[axis] || String(a.id).localeCompare(String(b.id)));
const parts = [];
for (let i = 0; i < sorted.length; i += maxItems) parts.push(sorted.slice(i, i + maxItems));
return parts;
}
function spatialBuckets(items, cellSize, maxItems) {
const buckets = new Map();
for (const item of items) {
const key = `${Math.floor(item.x / cellSize)}:${Math.floor(item.y / cellSize)}:${Math.floor(item.z / cellSize)}`;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(item);
}
const result = [];
const entries = [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [, bucket] of entries) result.push(...splitSpatial(bucket, maxItems));
return result;
}
function makeLODGroup(level, cluster, members, children, ordinal) {
let sumX = 0, sumY = 0, sumZ = 0, mass = 0;
for (const member of members) {
const weight = 1 + Math.min(8, Math.sqrt((state.adjacency.get(member.id) || []).length));
sumX += member.x * weight;
sumY += member.y * weight;
sumZ += member.z * weight;
mass += weight;
}
const signature = `${cluster.key}|${level}|${members[0]?.id || ordinal}|${members.length}|${ordinal}`;
const id = `lod:${level}:${hashString(signature).toString(36)}`;
const statuses = new Set(members.map(member => member.status).filter(Boolean));
return {
id, kind: 'supernode', renderKind: 'supernode', lodLevel: level, clusterKey: cluster.key, cluster,
label: cluster.label, summary: `${members.length.toLocaleString('de-DE')} verdichtete Wissenselemente`,
status: statuses.size === 1 ? [...statuses][0] : 'aggregate', origin: 'lod', categories: [cluster.label],
x: sumX / Math.max(1, mass), y: sumY / Math.max(1, mass), z: sumZ / Math.max(1, mass),
weight: Math.max(1, Math.log2(members.length + 1)), clusterColor: cluster.color,
members, memberIDs: members.map(member => member.id), memberCount: members.length, children: children || [],
internalEdgeCount: 0, externalEdgeCount: 0, screen: null, transitionFrom: null, transitionStart: 0
};
}
function buildLODHierarchy() {
state.lodLeaves = [];
state.lodCoarse = [];
state.lodGroupById = new Map();
state.lodLeafByNode = new Map();
state.lodCoarseByNode = new Map();
for (const cluster of state.clusters) {
const localParts = spatialBuckets(cluster.nodes, LOD_CONFIG.localDistance, LOD_CONFIG.localMaxMembers);
const leaves = localParts.map((members, index) => makeLODGroup(1, cluster, members, [], index));
for (const leaf of leaves) {
state.lodLeaves.push(leaf);
state.lodGroupById.set(leaf.id, leaf);
for (const id of leaf.memberIDs) state.lodLeafByNode.set(id, leaf);
}
const coarseParts = spatialBuckets(leaves, LOD_CONFIG.coarseDistance, LOD_CONFIG.coarseMaxChildren);
const coarseGroups = coarseParts.map((children, index) => {
const members = children.flatMap(child => child.members);
const group = makeLODGroup(2, cluster, members, children, index);
for (const child of children) child.parentID = group.id;
return group;
});
cluster.lodLeaves = leaves;
cluster.lodCoarse = coarseGroups;
for (const coarse of coarseGroups) {
state.lodCoarse.push(coarse);
state.lodGroupById.set(coarse.id, coarse);
for (const id of coarse.memberIDs) state.lodCoarseByNode.set(id, coarse);
}
}
state.lodDirty = true;
}
function lodGroupOpen(group, now) {
return (state.lodOpenUntil.get(group.id) || 0) > now;
}
function nodeHot(id, now) {
return (state.lodHotUntil.get(id) || 0) > now || (state.active.get(id) || 0) > 0.04 || state.focusNodeID === id || state.selected?.id === id;
}
function groupHasHotNode(group, now) {
if (lodGroupOpen(group, now)) return true;
for (const id of group.memberIDs) if (nodeHot(id, now)) return true;
return false;
}
function revealLODNodes(ids, duration = LOD_CONFIG.revealNodeMS) {
const now = performance.now();
for (const id of ids || []) {
if (!state.nodeById.has(id)) continue;
state.lodHotUntil.set(id, Math.max(state.lodHotUntil.get(id) || 0, now + duration));
const leaf = state.lodLeafByNode.get(id);
const coarse = state.lodCoarseByNode.get(id);
if (leaf) state.lodOpenUntil.set(leaf.id, Math.max(state.lodOpenUntil.get(leaf.id) || 0, now + duration));
if (coarse) state.lodOpenUntil.set(coarse.id, Math.max(state.lodOpenUntil.get(coarse.id) || 0, now + Math.max(duration, LOD_CONFIG.revealParentMS)));
}
state.lodDirty = true;
}
function openLODGroup(group) {
const now = performance.now();
const duration = group.lodLevel === 2 ? LOD_CONFIG.revealParentMS : LOD_CONFIG.revealNodeMS;
state.lodOpenUntil.set(group.id, now + duration);
if (group.lodLevel === 1) {
const parent = state.lodGroupById.get(group.parentID);
if (parent) state.lodOpenUntil.set(parent.id, now + LOD_CONFIG.revealParentMS);
}
state.lodDirty = true;
rebuildRenderGraph(now, true);
}
function updateLODZoomBand() {
const before = state.lodZoomBand;
if (state.lodZoomBand === 2 && state.zoom > 1.12) state.lodZoomBand = 1;
else if (state.lodZoomBand === 1 && state.zoom < 0.96) state.lodZoomBand = 2;
else if (state.lodZoomBand === 1 && state.zoom > 1.72) state.lodZoomBand = 0;
else if (state.lodZoomBand === 0 && state.zoom < 1.48) state.lodZoomBand = 1;
if (before !== state.lodZoomBand) state.lodDirty = true;
}
function addVisibleEntity(entity, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now) {
entity.renderKind = entity.kind === 'supernode' ? 'supernode' : 'node';
entity.memberCount = entity.kind === 'supernode' ? entity.memberCount : 1;
entity.internalEdgeCount = 0;
entity.externalEdgeCount = 0;
const sampleID = entity.kind === 'supernode' ? entity.memberIDs[0] : entity.id;
const oldVisibleID = oldVisibleForNode.get(sampleID);
const oldEntity = oldVisibleID ? oldRenderNodeById.get(oldVisibleID) : null;
if (!oldRenderNodeById.has(entity.id) && oldEntity) {
entity.transitionFrom = {x: oldEntity.x, y: oldEntity.y, z: oldEntity.z};
entity.transitionStart = now;
}
renderNodes.push(entity);
renderNodeById.set(entity.id, entity);
if (entity.kind === 'supernode') {
for (const id of entity.memberIDs) visibleForNode.set(id, entity.id);
} else {
visibleForNode.set(entity.id, entity.id);
}
}
function rebuildRenderGraph(now = performance.now(), force = false) {
if (!force && (!state.lodDirty || now - state.lodLastBuild < LOD_CONFIG.rebuildThrottleMS)) return;
const oldRenderNodeById = state.renderNodeById;
const oldVisibleForNode = state.visibleForNode;
const renderNodes = [];
const renderNodeById = new Map();
const visibleForNode = new Map();
if (!state.lodEnabled) {
for (const node of state.nodes) addVisibleEntity(node, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now);
} else {
for (const coarse of state.lodCoarse) {
const focused = coarse.clusterKey === state.focusClusterKey;
const coarseOpen = state.lodZoomBand <= 1 || focused || groupHasHotNode(coarse, now);
if (!coarseOpen && coarse.memberCount > 1) {
addVisibleEntity(coarse, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now);
continue;
}
for (const leaf of coarse.children) {
const leafOpen = state.lodZoomBand === 0 || groupHasHotNode(leaf, now);
if (!leafOpen && leaf.memberCount > 1) {
addVisibleEntity(leaf, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now);
} else {
for (const node of leaf.members) addVisibleEntity(node, renderNodes, renderNodeById, visibleForNode, oldRenderNodeById, oldVisibleForNode, now);
}
}
}
}
const renderEdgesByKey = new Map();
const edgeRenderMap = new Map();
for (const edge of state.edges) {
const source = visibleForNode.get(edge.source);
const target = visibleForNode.get(edge.target);
if (!source || !target) continue;
if (source === target) {
const entity = renderNodeById.get(source);
if (entity) entity.internalEdgeCount++;
edgeRenderMap.set(edge.id, '');
continue;
}
const key = `${source}\u0000${target}\u0000${edge.type || ''}\u0000${edge.origin || ''}\u0000${edge.status || ''}`;
let aggregate = renderEdgesByKey.get(key);
if (!aggregate) {
aggregate = {
id: `lod-edge:${hashString(key).toString(36)}`, source, target, type: edge.type, origin: edge.origin, status: edge.status,
confidence: edge.confidence || 0, weight: 0, edgeCount: 0, weightSum: 0, maxConfidence: edge.confidence || 0,
memberEdgeIDs: [], explanation: edge.explanation || ''
};
renderEdgesByKey.set(key, aggregate);
}
aggregate.edgeCount++;
aggregate.weightSum += edge.weight || 1;
aggregate.maxConfidence = Math.max(aggregate.maxConfidence, edge.confidence || 0);
aggregate.confidence = aggregate.maxConfidence;
aggregate.weight = Math.log1p(aggregate.weightSum);
if (aggregate.memberEdgeIDs.length < 32) aggregate.memberEdgeIDs.push(edge.id);
edgeRenderMap.set(edge.id, aggregate.id);
}
const renderEdges = [...renderEdgesByKey.values()];
for (const edge of renderEdges) {
const source = renderNodeById.get(edge.source);
const target = renderNodeById.get(edge.target);
if (source) source.externalEdgeCount += edge.edgeCount;
if (target) target.externalEdgeCount += edge.edgeCount;
}
state.renderNodes = renderNodes;
state.renderEdges = renderEdges;
state.renderIdleEdges = renderEdges.filter(edge => edge.edgeCount > 1 || edge.origin === 'ai-inference' || edge.type === 'contradicts' || edge.type === 'supports');
state.renderNodeById = renderNodeById;
state.renderEdgeById = new Map(renderEdges.map(edge => [edge.id, edge]));
state.visibleForNode = visibleForNode;
state.edgeRenderMap = edgeRenderMap;
state.renderActive = new Map();
for (const [id, strength] of state.active) {
const visibleID = visibleForNode.get(id);
if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, strength));
}
state.renderEdgeActive = new Map();
for (const [id, strength] of state.edgeActive) {
const visibleID = edgeRenderMap.get(id);
if (visibleID) state.renderEdgeActive.set(visibleID, Math.max(state.renderEdgeActive.get(visibleID) || 0, strength));
}
state.renderStats = {
nodes: renderNodes.length,
edges: renderEdges.length,
hiddenNodes: Math.max(0, state.nodes.length - renderNodes.length),
hiddenEdges: Math.max(0, state.edges.length - renderEdges.length)
};
const renderCount = $('renderCount');
if (renderCount) {
renderCount.textContent = renderNodes.length.toLocaleString('de-DE');
renderCount.parentElement.title = `${renderNodes.length.toLocaleString('de-DE')} von ${state.nodes.length.toLocaleString('de-DE')} Nodes · ${renderEdges.length.toLocaleString('de-DE')} aggregierte Edges`;
}
const lodButton = $('toggleLOD');
if (lodButton) lodButton.title = `Hierarchisches LOD: ${renderNodes.length.toLocaleString('de-DE')} Render-Nodes / ${renderEdges.length.toLocaleString('de-DE')} Render-Edges`;
state.lodLastBuild = now;
state.lodDirty = false;
state.lodNextExpiry = Infinity;
for (const expires of state.lodOpenUntil.values()) if (expires > now) state.lodNextExpiry = Math.min(state.lodNextExpiry, expires);
for (const expires of state.lodHotUntil.values()) if (expires > now) state.lodNextExpiry = Math.min(state.lodNextExpiry, expires);
if (!Number.isFinite(state.lodNextExpiry)) state.lodNextExpiry = 0;
if (state.hover && !renderNodeById.has(state.hover.id)) state.hover = null;
}
function updateLOD(now) {
updateLODZoomBand();
if (state.lodNextExpiry && now >= state.lodNextExpiry) state.lodDirty = true;
if (state.lodDirty) rebuildRenderGraph(now);
}
function rotatePoint(n) {
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
@@ -543,14 +824,25 @@
function projectAnimatedNode(node, now) {
const cluster = node.cluster;
let x = node.x, y = node.y, z = node.z;
if (node.transitionFrom && node.transitionStart) {
const t = Math.max(0, Math.min(1, (now - node.transitionStart) / 520));
const eased = t * t * (3 - 2 * t);
x = node.transitionFrom.x + (x - node.transitionFrom.x) * eased;
y = node.transitionFrom.y + (y - node.transitionFrom.y) * eased;
z = node.transitionFrom.z + (z - node.transitionFrom.z) * eased;
if (t >= 1) {
node.transitionFrom = null;
node.transitionStart = 0;
}
}
if (cluster) {
const clusterEnergy = state.clusterActive.get(cluster.key) || 0;
const learning = state.mode === 'learning';
const ambient = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * (learning ? 0.0025 : 0.012);
const expansion = ambient + clusterEnergy * (learning ? 0.006 : 0.055) + (cluster.key === state.focusClusterKey && !learning ? state.activityEnergy * 0.028 : 0);
x = cluster.x + (node.x - cluster.x) * expansion;
y = cluster.y + (node.y - cluster.y) * expansion;
z = cluster.z + (node.z - cluster.z) * expansion;
x = cluster.x + (x - cluster.x) * expansion;
y = cluster.y + (y - cluster.y) * expansion;
z = cluster.z + (z - cluster.z) * expansion;
}
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
@@ -688,20 +980,27 @@
if (!state.edgesVisible) return;
ctx.save();
ctx.globalCompositeOperation = 'screen';
for (const e of state.edges) {
const a = state.nodeById.get(e.source), b = state.nodeById.get(e.target);
const edges = [...state.renderIdleEdges];
const included = new Set(edges.map(edge => edge.id));
for (const id of state.renderEdgeActive.keys()) {
const edge = state.renderEdgeById.get(id);
if (edge && !included.has(id)) { edges.push(edge); included.add(id); }
}
for (const edge of edges) {
const a = state.renderNodeById.get(edge.source), b = state.renderNodeById.get(edge.target);
if (!a?.screen || !b?.screen) continue;
const active = state.edgeActive.get(e.id) || 0;
const base = active > 0.01 ? 0.08 + active * 0.7 : e.origin === 'ai-inference' ? 0.03 : e.type === 'categorized_as' ? 0.02 : 0.009;
const alpha = Math.min(0.85, base);
if (alpha < 0.012) continue;
const active = state.renderEdgeActive.get(edge.id) || 0;
const density = Math.min(1, Math.log1p(edge.edgeCount || 1) / 5.5);
const base = active > 0.01 ? 0.08 + active * 0.7 : edge.origin === 'ai-inference' ? 0.026 : edge.type === 'categorized_as' ? 0.017 : 0.006 + density * 0.014;
const alpha = Math.min(0.88, base);
if (alpha < 0.01) continue;
const mx = (a.screen.x + b.screen.x) / 2;
const my = (a.screen.y + b.screen.y) / 2 - Math.abs(a.screen.x - b.screen.x) * 0.035;
ctx.beginPath();
ctx.moveTo(a.screen.x, a.screen.y);
ctx.quadraticCurveTo(mx, my, b.screen.x, b.screen.y);
ctx.strokeStyle = e.origin === 'ai-inference' ? `rgba(255,180,82,${alpha})` : `rgba(82,181,255,${alpha})`;
ctx.lineWidth = 0.25 + active * 1.7 + (e.confidence || 0) * 0.2;
ctx.strokeStyle = edge.origin === 'ai-inference' ? `rgba(255,180,82,${alpha})` : `rgba(82,181,255,${alpha})`;
ctx.lineWidth = 0.25 + active * 1.7 + (edge.confidence || 0) * 0.18 + density * 0.65;
ctx.stroke();
}
ctx.restore();
@@ -711,21 +1010,41 @@
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = state.particles.length - 1; i >= 0; i--) {
const p = state.particles[i];
p.t += dt * p.speed;
const e = state.edgeById.get(p.edgeId), a = e && state.nodeById.get(e.source), b = e && state.nodeById.get(e.target);
if (!e || !a?.screen || !b?.screen || p.t > 1.08) {
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, p.t);
const q = Math.min(1, particle.t);
const ease = q * q * (3 - 2 * q);
const x = a.screen.x + (b.screen.x - a.screen.x) * ease;
const y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18;
const size = 8 * (p.size || 1) * (0.82 + state.activityEnergy * 0.28);
let x, y;
if (a === b) {
const angle = q * Math.PI * 2 + hashString(particle.edgeId) * 0.00001;
const orbit = 8 + Math.min(18, Math.log1p(a.memberCount || 1) * 3);
x = a.screen.x + Math.cos(angle) * orbit;
y = a.screen.y + Math.sin(angle) * orbit * 0.55;
} else {
x = a.screen.x + (b.screen.x - a.screen.x) * ease;
y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18;
}
const size = 8 * (particle.size || 1) * (0.82 + state.activityEnergy * 0.28);
const grad = ctx.createRadialGradient(x, y, 0, x, y, size);
grad.addColorStop(0, 'rgba(255,255,255,.95)');
grad.addColorStop(0.22, p.color);
grad.addColorStop(0.22, particle.color);
grad.addColorStop(1, 'rgba(82,231,255,0)');
ctx.fillStyle = grad;
ctx.beginPath();
@@ -736,48 +1055,74 @@
}
function renderNodes(now, dt) {
for (const [id, value] of state.active) {
const next = Math.max(0, value - dt * 0.44);
if (next > 0) state.active.set(id, next); else state.active.delete(id);
}
for (const [id, value] of state.renderActive) {
const next = Math.max(0, value - dt * 0.44);
if (next > 0) state.renderActive.set(id, next); else state.renderActive.delete(id);
}
state.projected = [];
for (const n of state.nodes) {
n.screen = projectAnimatedNode(n, now);
state.projected.push(n);
for (const node of state.renderNodes) {
node.screen = projectAnimatedNode(node, now);
state.projected.push(node);
}
state.projected.sort((a, b) => a.screen.z - b.screen.z);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const n of state.projected) {
let act = state.active.get(n.id) || 0;
act = Math.max(0, act - dt * 0.44);
if (act > 0) state.active.set(n.id, act); else state.active.delete(n.id);
const hover = state.hover?.id === n.id;
const selected = state.selected?.id === n.id;
const degree = Math.min(18, (state.adjacency.get(n.id) || []).length);
const baseWeight = Math.max(0.25, Math.min(2.5, (n.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05));
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + n.x * 7 + n.y * 9);
const clusterEnergy = state.clusterActive.get(n.clusterKey) || 0;
const focused = n.clusterKey === state.focusClusterKey ? state.activityEnergy : 0;
const r = (1.05 + baseWeight * 0.7 + n.screen.p * 0.55) * (1 + act * 0.48 + clusterEnergy * 0.06 + focused * 0.025) + (hover || selected ? 1.6 : 0);
for (const node of state.projected) {
const isGroup = node.kind === 'supernode';
const act = state.renderActive.get(node.id) || (isGroup ? 0 : state.active.get(node.id) || 0);
const hover = state.hover?.id === node.id;
const selected = state.selected?.id === node.id;
const degree = Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length);
const memberScale = isGroup ? Math.min(7.5, 1.1 + Math.log2((node.memberCount || 1) + 1) * 0.72) : 0;
const baseWeight = isGroup ? memberScale : Math.max(0.25, Math.min(2.5, (node.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05));
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + node.x * 7 + node.y * 9);
const clusterEnergy = state.clusterActive.get(node.clusterKey) || 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 alpha = Math.min(1, 0.17 - idleDim + n.screen.p * 0.1 + Math.min(0.22, degree * 0.006) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0));
if (act > 0.05 || hover || selected || n.kind === 'ai-think') {
const halo = ctx.createRadialGradient(n.screen.x, n.screen.y, 0, n.screen.x, n.screen.y, r * (3 + act * 4));
halo.addColorStop(0, nodeColor(n, 0.48 + act * 0.35));
halo.addColorStop(0.24, nodeColor(n, 0.14 + act * 0.2));
halo.addColorStop(1, nodeColor(n, 0));
const alpha = Math.min(1, (isGroup ? 0.42 : 0.17) - idleDim + node.screen.p * 0.1 + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0));
if (act > 0.05 || hover || selected || node.kind === 'ai-think' || isGroup) {
const haloRadius = r * (isGroup ? 2.35 + act * 2.4 : 3 + act * 4);
const halo = ctx.createRadialGradient(node.screen.x, node.screen.y, 0, node.screen.x, node.screen.y, haloRadius);
halo.addColorStop(0, nodeColor(node, (isGroup ? 0.28 : 0.48) + act * 0.35));
halo.addColorStop(0.24, nodeColor(node, (isGroup ? 0.09 : 0.14) + act * 0.2));
halo.addColorStop(1, nodeColor(node, 0));
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(n.screen.x, n.screen.y, r * (3 + act * 4), 0, Math.PI * 2);
ctx.arc(node.screen.x, node.screen.y, haloRadius, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = nodeColor(n, alpha);
ctx.fillStyle = nodeColor(node, alpha);
ctx.beginPath();
ctx.arc(n.screen.x, n.screen.y, r * (0.84 + breathe * 0.1), 0, Math.PI * 2);
ctx.arc(node.screen.x, node.screen.y, r * (0.84 + breathe * 0.1), 0, Math.PI * 2);
ctx.fill();
if (n.kind === 'ai-think') {
ctx.strokeStyle = nodeColor(n, 0.55);
if (isGroup) {
ctx.strokeStyle = nodeColor(node, 0.4 + act * 0.3);
ctx.lineWidth = 0.7 + Math.min(1.4, Math.log1p(node.internalEdgeCount || 0) * 0.12);
ctx.beginPath();
ctx.arc(node.screen.x, node.screen.y, r * 1.28 + breathe * 1.3, 0, Math.PI * 2);
ctx.stroke();
if (node.memberCount >= 8 && r > 5) {
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.font = '600 8px Inter, system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(235,248,255,.82)';
ctx.fillText(node.memberCount > 999 ? `${Math.round(node.memberCount / 100) / 10}k` : String(node.memberCount), node.screen.x, node.screen.y + 0.5);
ctx.restore();
}
} else if (node.kind === 'ai-think') {
ctx.strokeStyle = nodeColor(node, 0.55);
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.arc(n.screen.x, n.screen.y, r * 2 + breathe * 1.8, 0, Math.PI * 2);
ctx.arc(node.screen.x, node.screen.y, r * 2 + breathe * 1.8, 0, Math.PI * 2);
ctx.stroke();
}
}
@@ -787,15 +1132,17 @@
ctx.save();
ctx.font = '10px Inter, system-ui';
ctx.textBaseline = 'middle';
for (const n of state.projected) {
const act = state.active.get(n.id) || 0;
if (!(act > 0.45 || state.hover?.id === n.id || state.selected?.id === n.id || (n.kind === 'ai-think' && n.screen.p > 1.02))) continue;
const label = n.label.length > 36 ? n.label.slice(0, 34) + '…' : n.label;
for (const node of state.projected) {
const act = state.renderActive.get(node.id) || (node.kind === 'supernode' ? 0 : state.active.get(node.id) || 0);
const isGroup = node.kind === 'supernode';
if (!(act > 0.45 || state.hover?.id === node.id || state.selected?.id === node.id || (!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,.82)';
ctx.fillRect(n.screen.x + 8, n.screen.y - 8, w, 16);
ctx.fillStyle = nodeColor(n, 0.95);
ctx.fillText(label, n.screen.x + 14, n.screen.y);
ctx.fillStyle = 'rgba(2,7,14,.84)';
ctx.fillRect(node.screen.x + 8, node.screen.y - 8, w, 16);
ctx.fillStyle = nodeColor(node, 0.95);
ctx.fillText(label, node.screen.x + 14, node.screen.y);
}
ctx.restore();
}
@@ -849,6 +1196,7 @@
const dt = Math.min(0.05, (now - state.last) / 1000);
state.last = now;
updateVisualState(now, dt);
updateLOD(now);
drawBackground(now);
renderActivityAura(now);
renderClusterClouds(now);
@@ -861,6 +1209,10 @@
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);
@@ -870,6 +1222,11 @@
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 (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);
@@ -878,10 +1235,14 @@
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));
}
for (const id of evt.edge_ids || []) {
state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength));
const renderID = state.edgeRenderMap.get(id);
if (renderID) state.renderEdgeActive.set(renderID, Math.max(state.renderEdgeActive.get(renderID) || 0, strength));
const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5));
for (let i = 0; i < count; i++) {
state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86});
@@ -891,8 +1252,10 @@
state.pulses++;
$('pulseCount').textContent = state.pulses.toLocaleString('de-DE');
const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null;
const x = focus?.screen?.x ?? state.width / 2;
const y = focus?.screen?.y ?? state.height / 2;
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});
@@ -1014,10 +1377,18 @@
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 = '';});
@@ -1069,20 +1440,32 @@
state.hover = best;
const tip = $('tooltip');
if (best) {
const degree = (state.adjacency.get(best.id) || []).length;
tip.classList.remove('hidden');
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 - 260, e.clientX + 14) + 'px';
tip.style.top = Math.min(state.height - 70, e.clientY + 14) + 'px';
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', e => {
canvas.addEventListener('pointerup', () => {
if (!state.moved && state.hover) {
state.selected = state.hover;
state.active.set(state.hover.id, 1.2);
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;
});
@@ -1090,6 +1473,8 @@
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) {

View File

@@ -25,6 +25,7 @@
<div class="metrics">
<span><b id="nodeCount">0</b> Nodes</span>
<span><b id="edgeCount">0</b> Edges</span>
<span title="Aktuell gerenderte LOD-Knoten"><b id="renderCount">0</b> Render</span>
<span><b id="pulseCount">0</b> Impulse</span>
<span class="state" id="systemState"><i></i> verbindet</span>
</div>
@@ -48,6 +49,7 @@
<button id="toggleEdges" class="active">Synapsen</button>
<button id="toggleRotate" class="active">Autopilot</button>
<button id="toggleCortex" class="active">Cortex</button>
<button id="toggleLOD" class="active" title="Hierarchische dynamische Verdichtung">LOD</button>
<button id="resetView">Zentrieren</button>
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
</nav>

Binary file not shown.