diff --git a/CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md b/CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md new file mode 100644 index 0000000..1e85a72 --- /dev/null +++ b/CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md @@ -0,0 +1,35 @@ +# Changelog: Constellation, ECO und weiche Graphübergänge + +## Visualisierung + +- neue semantische **Constellation**-Ansicht mit Themenzentren, stabilen Sternpositionen, aggregierten Themenverbindungen und Aktivitätsdarstellung; +- direkte Umschaltung zwischen Neural, Honeycomb und Constellation; +- FPS-Anzeige in der Statusleiste; +- Constellation als persistierbarer `view_mode` und als `BRAIN_DEFAULT_VIEW` unterstützt. + +## Performance + +- persistierbarer **ECO**-Schalter für schwächere Systeme; +- ungefähr 30-FPS-Limit, reduzierte DPR und feste Budgets für Edges, Partikel, Wolken, Sterne und Recherchequellen; +- gecachter Offscreen-Hintergrund; +- Kamera- und Projektionsparameter nur einmal pro Frame; +- gedrosselte Tiefensortierung im ECO-Modus; +- stabile Honeycomb-Slots statt vollständiger Neuverteilung; +- zusammengeführte und entprellte Graphreloads; +- identische Snapshots werden ohne Renderarbeit verworfen; +- reine Kantenupdates erzeugen keinen kompletten Node-Layout-Neuaufbau. + +## Übergänge + +- Fade-in für neue Nodes und Edges; +- begrenzter Fade-out-Puffer für entfernte Elemente; +- Positionsmorphing bei Layoutänderungen; +- Ghost-Fade beim Wechsel des Ansichtsmodus; +- bestehende Node-Objekte und Positionen werden bei Graphupdates in-place erhalten; +- SearXNG-Animationen bleiben von Graph- und Viewübergängen unabhängig. + +## Fehlerkorrektur + +- fehlende oder `null`-basierte Research-Evidence-Metadaten werden nicht mehr zu `data/` beziehungsweise `data\\` aufgelöst; +- die wiederholte Warnung `stored research evidence could not be reused ... path:""` entfällt; +- Regressionstest für fehlende, `nil`, `` und `null` enthaltende `evidence_path`-Werte ergänzt. diff --git a/README.md b/README.md index 6813f2f..bb57b98 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Eigenständiger Go-Dienst für Agent, lokale Knowledgebase, GLPI-Knowledgebase u ## Kernfunktionen - Fullscreen-Canvas mit 3D-Hirnform, semantischen Cortex-Regionen und hierarchischem Level-of-Detail. -- Umschaltbare Honeycomb-Ansicht: gleichmäßig verteilte Notes in einer automatisch skalierten 3D-Gehirnwabe, ohne Edge-Rendering. +- Drei Visualisierungen: Neural-Brain, gleichmäßige 3D-Honeycomb-Wabe und semantische Constellation-Ansicht mit Themensternen. +- Persistierbarer **ECO**-Modus für schwächere Systeme sowie weiche Node-, Edge- und Ansichtsübergänge ohne sichtbaren Komplett-Reload. - Laufzeitsteuerung für Learning und Thinking sowie getrennte Kategorie-Filter für Lernen, Anzeige und AI-THINK. - Echtzeitaktivierung über Server-Sent Events: Nodes glühen, aggregierte Edges leuchten und Partikel folgen tatsächlichen Wissenspfaden. - Ingest lokaler produktiver Knowledge-JSONs sowie separater AI-THINK-Staging-Dateien. @@ -133,9 +134,9 @@ curl -fsS http://localhost:8090/api/state/export -o graph.db Mehr Details: [`PERSISTENCE.md`](PERSISTENCE.md) und [`SQLITE-STORAGE.md`](SQLITE-STORAGE.md). -## Laufzeitsteuerung und Honeycomb +## Laufzeitsteuerung, Visualisierungen und Performance -Die untere Steuerleiste enthält direkte Schalter für **LEARNING**, **THINKING**, **NEURAL** und **HONEYCOMB**. Sind Learning und Thinking deaktiviert, bleibt das System im Living-Modus; eingehende Agent- oder KB-Anfragen können weiterhin die tatsächlich verwendeten Notes aktivieren. +Die untere Steuerleiste enthält direkte Schalter für **LEARNING**, **THINKING**, **NEURAL**, **HONEYCOMB**, **CONSTELLATION** und **ECO**. Sind Learning und Thinking deaktiviert, bleibt das System im Living-Modus; eingehende Agent- oder KB-Anfragen können weiterhin die tatsächlich verwendeten Notes aktivieren. Über **FILTER** lassen sich drei unabhängige Kategorienlisten pflegen: @@ -151,13 +152,22 @@ BRAIN_THINKING_ENABLED=true BRAIN_LEARNING_CATEGORIES= BRAIN_DISPLAY_CATEGORIES= BRAIN_THINKING_CATEGORIES= -BRAIN_DEFAULT_VIEW=neural +BRAIN_DEFAULT_VIEW=neural # neural | honeycomb | constellation BRAIN_MAX_DISPLAY_NODES=0 # 0 = unbegrenzt; alternativ z. B. 5000 +BRAIN_LOW_POWER_MODE=false ``` -Honeycomb rendert nur `knowledge`, `ai-think` und `external`. Ein 3D-Gitter mit einheitlichem Punktabstand wird auf die Gehirngeometrie beschnitten und automatisch an die sichtbare Anzahl von Notes angepasst. Edges, LOD-Gruppen und Cortex-Flächen bleiben dort unsichtbar; bei einer Anfrage leuchten nur die referenzierten Notes. +Die Ansichten erfüllen unterschiedliche Aufgaben: -Mehr Details: [`RUNTIME-CONTROLS-HONEYCOMB.md`](RUNTIME-CONTROLS-HONEYCOMB.md). +- **Neural** zeigt das lebende semantische Netz mit LOD, Cortex, Synapsen und Partikeln. +- **Honeycomb** rendert `knowledge`, `ai-think` und `external` gleichmäßig in einer 3D-Gehirnwabe. Reguläre Edges und Cortex-Flächen bleiben dort unsichtbar. +- **Constellation** ordnet Wissenselemente als Themensterne um semantische Zentren an. Dezente Bahnen und aggregierte Themenverbindungen machen Fachgebiete und Übergänge leichter lesbar als ein dichtes Gesamtnetz. + +Der **ECO**-Schalter begrenzt die Ausgabe auf ungefähr 30 FPS, reduziert die Device-Pixel-Ratio, teure Gradienten, Hintergrundsterne, gleichzeitig sichtbare inaktive Edges, Partikel und Wolken. Aktivierte Nodes, SearXNG-Animationen, Themenzentren und Übergänge bleiben sichtbar. Das ist zusätzlich zum GPU-Node-Limit; der ECO-Modus entfernt keine Wissenselemente. + +Graphupdates verändern vorhandene Node-Objekte und Positionen in-place. Neue Elemente blenden ein, entfernte Elemente blenden aus, Ansichtswechsel morphen zwischen den Koordinatenräumen. Unveränderte Snapshots werden übersprungen und reine Edgeupdates lösen keinen vollständigen Layout-Neuaufbau aus. + +Mehr Details: [`VISUALIZATION-PERFORMANCE.md`](VISUALIZATION-PERFORMANCE.md) und [`RUNTIME-CONTROLS-HONEYCOMB.md`](RUNTIME-CONTROLS-HONEYCOMB.md). ## Autonome Anreicherung diff --git a/RUNTIME-CONTROLS-HONEYCOMB.md b/RUNTIME-CONTROLS-HONEYCOMB.md index 8edec9b..4cd644c 100644 --- a/RUNTIME-CONTROLS-HONEYCOMB.md +++ b/RUNTIME-CONTROLS-HONEYCOMB.md @@ -1,4 +1,4 @@ -# Runtime Controls und Honeycomb +# Runtime Controls, Ansichten und ECO Die Fullscreen-Oberfläche kann die aktiven Hintergrundprozesse und die Darstellung ohne Neustart umschalten. Die Einstellungen gelten sofort im Arbeitsspeicher und werden über die gebündelte Persistenz in `BRAIN_DATA_DIR/runtime-settings.json` geschrieben. @@ -23,12 +23,22 @@ Mit **HONEYCOMB** wird die semantische Graphansicht durch eine gleichmäßig bes - Edges, Partikelpfade, Cortex-Flächen und LOD-Supernodes werden in dieser Ansicht nicht gerendert. - Wird eine Note durch Agent, Knowledgebase, Retrieval oder AI-THINK referenziert, leuchtet genau ihr Wabenpunkt auf. -Die Honeycomb-Ansicht verändert weder den Graphen noch die gespeicherten Node-Positionen. Sie ist eine reine Renderprojektion und kann jederzeit zurück auf **NEURAL** geschaltet werden. +Die Honeycomb-Ansicht verändert weder den Graphen noch die gespeicherten Node-Positionen. Sie ist eine reine Renderprojektion und kann jederzeit zurück auf **NEURAL** geschaltet werden. Ihre Slotbelegung bleibt bei Graphupdates stabil; neue Notes belegen freie Plätze, statt die komplette Wabe neu zu sortieren. + +## Constellation-Ansicht + +Mit **CONSTELLATION** werden semantische Themenbereiche als ruhige Zentren mit umlaufenden Wissenssternen dargestellt. Aggregierte Verbindungen zwischen den Themen machen Wissensbrücken sichtbar, ohne jede Einzelkante zu rendern. Aktivierte Notes und Rechercheereignisse leuchten weiterhin auf. + +## ECO-Modus + +Der persistierbare Schalter **ECO** optimiert die Darstellung für schwächere Systeme, ohne die Kernanimationen zu entfernen. Er begrenzt die Ausgabe auf ungefähr 30 FPS, reduziert die Device-Pixel-Ratio sowie Budgets für inaktive Edges, Partikel, Wolken, Sterne und Quellenpunkte. Aktive Nodes, Rechercheanimationen, Fade-Übergänge und Themenzentren bleiben sichtbar. + +Der ECO-Modus ist unabhängig vom GPU-Node-Limit: ECO reduziert die Kosten pro Frame, das Node-Limit reduziert die Anzahl aufgebauter Elemente. ## GPU-Limit für schwächere Systeme -`max_display_nodes` begrenzt die Anzahl der Nodes, die im Browser für Neural und Honeycomb aufgebaut werden. `0` bedeutet unbegrenzt. Die Begrenzung wird nach dem Anzeige-Kategoriefilter angewendet. Das vollständige Wissen bleibt im Backend und im geladenen Graph-Snapshot erhalten. +`max_display_nodes` begrenzt die Anzahl der Nodes, die im Browser für Neural, Honeycomb und Constellation aufgebaut werden. `0` bedeutet unbegrenzt. Die Begrenzung wird nach dem Anzeige-Kategoriefilter angewendet. Das vollständige Wissen bleibt im Backend und im geladenen Graph-Snapshot erhalten. Die Auswahl ist stabil und bevorzugt aktive Notes, AI-THINK/Staging-Inhalte, Hubs und eine Grundabdeckung der vorhandenen Themenbereiche. Wird eine aktuell nicht dargestellte Note von Agent, Knowledgebase oder AI-THINK angefordert, ersetzt sie temporär einen inaktiven Node. Nach der Aktivitätsphase greift das Limit wieder auf die stabile Grundauswahl zurück. @@ -52,7 +62,7 @@ Zulässig sind Werte von `0` bis `500000`. Die Einstellung reduziert die Render- ### Anzeige -`display_categories` begrenzt die im Browser gerenderten Notes. Direkt verbundene Kategorie-, Quellen- und Taxonomie-Nodes werden in der Neural-Ansicht mitgeführt. In Honeycomb werden ausschließlich passende Notes gerendert. +`display_categories` begrenzt die im Browser gerenderten Notes. Direkt verbundene Kategorie-, Quellen- und Taxonomie-Nodes werden in der Neural-Ansicht mitgeführt. In Honeycomb und Constellation werden ausschließlich passende Notes gerendert. Der vollständige Graph bleibt serverseitig erhalten. Der Filter ist keine Lösch- oder Zugriffsregel. @@ -74,9 +84,10 @@ BRAIN_DISPLAY_CATEGORIES= BRAIN_THINKING_CATEGORIES= BRAIN_DEFAULT_VIEW=neural BRAIN_MAX_DISPLAY_NODES=0 +BRAIN_LOW_POWER_MODE=false ``` -Zulässige Werte für `BRAIN_DEFAULT_VIEW` sind `neural` und `honeycomb`. +Zulässige Werte für `BRAIN_DEFAULT_VIEW` sind `neural`, `honeycomb` und `constellation`. Nach der ersten Änderung im Web haben die in `runtime-settings.json` gespeicherten Laufzeitwerte Vorrang vor den Startwerten. Zum Zurücksetzen kann die Datei bei gestopptem Dienst entfernt werden. @@ -99,8 +110,9 @@ curl -X PUT http://localhost:8090/api/runtime-settings \ "learning_categories": [], "display_categories": ["GLPI"], "thinking_categories": ["GLPI", "Ollama"], - "view_mode": "honeycomb", - "max_display_nodes": 5000 + "view_mode": "constellation", + "max_display_nodes": 5000, + "low_power_mode": true }' ``` diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index 850a5a8..67d120a 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1,57 +1,81 @@ -33629e5d0420fa9011643291b5a71bc543cd318e4f5fd01c12ac5978710e3589 ./.env.example -65faec43717b4df28bfb23d3a04211fbde4cfff165749dc40f37af3f3c774d4d ./ARCHITECTURE.md -91f75b87bd95480fd40f191b551fe2d5f9f64e590e7c5442c19ee6166a362e6e ./CHANGELOG-GLPI-POOL-PERSISTENCE.md +6a1332aec3461a9852258f428366903f9266369e474c27083a43d273c2ff1609 ./.env.example +8955cfbeff229e73f0cad664863ff21711c270a4233c3225680c89aa6268a901 ./ARCHITECTURE.md +2bc149241c2d25f755e4a0470dc517f646527ee3e98d3a6c2e7798639bd70866 ./CHANGELOG-CONSTELLATION-ECO-TRANSITIONS.md +213ac897cd415bbeb9764a847843b9eff366983cf25bb3cbce4efa4c04b9e5f5 ./CHANGELOG-GLPI-POOL-PERSISTENCE.md e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GPU-NODE-LIMIT.md a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md +02a8d3e541967e2d2ef9dd5451f470679e262ad851aca9f03563b322914c3181 ./CHANGELOG-ITERATIVE-GROUNDED-RESEARCH.md e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md 87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md +2d04e6d385f4b902080a0bcaab510a846a8ae6a76cb757423c50425c8433e7f9 ./CHANGELOG-SEARXNG-DIAGNOSTICS.md 9c760e167a9af2d3d8ca32c5aa4ef6bb4a3153047343a71c4b19ca9aef96ca32 ./CHANGELOG-SEARXNG-VISUALIZATION.md -99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile +5433a7c2e67ab35fb320bc872e9024fa5f3e765736184e9f878340b8b45407aa ./CHANGELOG-SQLITE-STARTUP-FIX.md +5b9deeab0cd59b3c649fd73f129361a1e773ed3955cded0048b8cb280bb32e88 ./CHANGELOG-SQLITE-STORAGE.md +42c1cacdc1f792773622017c3528dc626674456f478c41aaf44ff0a32e5f87bd ./Dockerfile 5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md -a090708c29db08973f712eb9ebf07dff2aae4e3e64f8954bbdaa83848b3ffcc0 ./KNOWLEDGE-SYNTHESIS.md -adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile +c546524a3add0b1beb8d1ca675fd602e2ea7c7bddfc04759519c21a02667eb61 ./ITERATIVE-GROUNDED-RESEARCH.md +e3ae87108607ca494668a9974c467a5c529b9599bf88d3d2a79ddc15b64e4a38 ./KNOWLEDGE-SYNTHESIS.md +696d2da2338cd8190b9614707e4059d78ce291e7334f273633aad815c3b6a6df ./Makefile 381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md -10c14f4c08b9b84699cc4cf0dac3e6faf6c0ffd7e74b428463f14c628d0b533d ./PERSISTENCE.md -b16c58d98f9da5d240ab5189993c5013aa57ac0da88029f40f3f3e572fdd8233 ./README.md -73171929c19afc9c0f280a50bee1c6becfbfe1a1a4107540ff80d5e4f885ba9a ./RUNTIME-CONTROLS-HONEYCOMB.md -c6fa5f19066c53fd184170bca78dceb8c3fbdfd0c5d6196e0ddbb37ac7bf8eb6 ./SEARXNG-VISUALIZATION.md -cfc2393e8300b792cec30c57b9dc7670b19dcf5abb190dddaa75f32b77cb1fc7 ./cmd/brain/main.go +2c0062941ef3edbd40d46b823934a7d0a3a9da7581b83d0b9360e8aaa7694b1b ./PERSISTENCE.md +892649393e1b5b0e427feb20410652c4094b8903ec3a97015a4af286d39e8834 ./README.md +dfa1c9fa47f53aa4fbe6eb8ee82f65c2a6213538af7b71a4842e40cdc271bab6 ./RUNTIME-CONTROLS-HONEYCOMB.md +c3da43b33e550901d55789f2ee526c2e50f61ee028a3f59e0a40e77e1057fde7 ./SEARXNG-VISUALIZATION.md +ae7bc1f1959071f79b76ca8a4ba103064ec0d5c5752af346175731ef4f636d9d ./SQLITE-STORAGE.md +e05449bab6250e585a6c0ac0735008cd533baf07dbf7149ddae609ae252d4425 ./VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md +e7ab2cddec372db906c7883a6e0861b71999043cfb9b94e527c3b2aee4532035 ./VALIDATION-ITERATIVE-GROUNDED-RESEARCH.md +e08b0eaab827fe03d5a72327e8fdfe9ce4025097e28208714246969e7d059561 ./VALIDATION-SEARXNG-DIAGNOSTICS.md +4cd120496664388717fe422a8c54380708df723fea26b35f81799665dfaf2c1c ./VALIDATION-SQLITE.md +f6699e4cdbaadc720e4b8a22c557d02319325283775a2b8a87ff78ca202e3386 ./VISUALIZATION-PERFORMANCE.md +8970f2abf17bddcd0d84387e8a4975588df2776f03a7a54c5a283470769ca0c8 ./cmd/brain/main.go +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./data/.gitkeep 21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md -139d3dc6939848f8d8e827e5a483da7f7794a47dd3c858a964eaa7f811987985 ./deployment/docker-compose.full.yml -5d97d77e2f0c302fb7fedfe8c7ff2c72a30f196590fdcab498ab0f4369de0071 ./docker-compose.yml -9126f8bca1144abfc77747063b2c8f31acf12839a75e060b98369e10a00061ca ./go.mod +e3403fd3a7ea9e467b09f7ced5ab63c8368fa4a75370ed62aa58b1cc7006177b ./deployment/docker-compose.full.yml +ad6e2f4d47004232a09620eb83b59b582833a9901db3b2eae756febbb1abcc67 ./docker-compose.yml +1542b064707bef0c4488f558f77c02db5f4cfc8d4da228f615ebdc3ee9ace8fd ./go.mod +864c3376212497b070feca13d26cfc28e96876078ce7a0b5b0c0470e2dd4fbf8 ./go.sum 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 -45ca13d5dc65b5858359d1187147c8a892ae6ba8355e699c05d7e44212096c37 ./internal/config/config.go -d5defa74dff6b09869f59c3e40ecc083f18bb9d81a0541956a857c4d0c26c360 ./internal/config/config_test.go -fec14e27424873edae74b6a6f304612208e0f0d23d3cb598f0e680238b6ba7c1 ./internal/engine/article.go -81386f1ce7d3d4d311367e8692429db6ced99173de9a623953aa38f1dd1725e3 ./internal/engine/engine.go -6499fa71d3554845a7a12f6a16c38579f457ece368a6f3b152ccebae39362662 ./internal/engine/engine_test.go +ccca255316e838042261da0329e44a4a36dd57fd44aa2a0ba5cccee6944f35a2 ./internal/config/config.go +f7d8c7f9051fa27ceeb2b23f92af2883ae659ad1dfa681c2723d010a08e977b8 ./internal/config/config_test.go +b81e53fc1e80a2d4c677e7145097473e772a7d963ffaddc7fa1eae2b2c95ebda ./internal/engine/article.go +d5d18a26010fe5f308c05b79e3a3d7501c8490bb33098357cde4373affd7e60d ./internal/engine/article_format_test.go +688c71c14f1112673bac63627738cfbd713272aae748db15204e9de04bf8d10b ./internal/engine/article_research.go +1e0fc5d81f2238927842d854eb062cef08e5c39b9970f68d4a181cba67d56d47 ./internal/engine/article_research_cache.go +3ca7037231935329d49b6b80553fbfef206c079a6aed4c2379dadd46d39ebc0d ./internal/engine/article_research_cache_test.go +289d7dca4f0294072521daa229ff2dcae7f159af4631d6567853f54b920506ce ./internal/engine/article_research_test.go +fc9f71e4bb8d400ec78bdc6e81d29722ffff17ddd9026ff97889ca7f306be92a ./internal/engine/engine.go +d2c5f2b64704656a4afe856b60872a29c1d491b16289144a5052b23c2900d08c ./internal/engine/engine_test.go +e3595489273b2e5f6b8686d7912e1d56c968fb8d105eea58973c25496e1cf412 ./internal/engine/research_diagnostics.go +0eb3f00e2ab73d2dc6a4cbc1a4038533a4bb20fbbbb6190abd39feff6b34b8e1 ./internal/engine/research_diagnostics_test.go 716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 ./internal/engine/research_events.go -872b9a5d6cc0e610a50f64ae8d66ed53bf07da718da22f91c11cf43a487fb425 ./internal/engine/runtime.go +7eb1c0cec5e4135d8e4988093f093d4ada7d1047db7b30243a7b5122c2af2a0f ./internal/engine/runtime.go b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go 525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go -4caa47f2d337086c09fa612a5b5b1855f17d57d736531b8badd7223f0345ba6d ./internal/graph/store.go -13c38603f7ad1dcc590117d06c0663ace3e7a5971b96b2a96e8a53d9bcae87ff ./internal/graph/store_test.go +6b3df883bb30e4d969a5aa7e4cb74ca98119ec1155f6b566555d06c270842c93 ./internal/graph/sqlite_backend.go +744d306b9c7151543e91421575775ff6eb1b7e0af187ba7d92d8a298c51704de ./internal/graph/sqlite_backend_test.go +1eff087ab94d6b94b5ae2205df0f1fcfcc3a35965808a3f504d2f96cb45459fe ./internal/graph/store.go +11daaf15e175818ddd62d70ac415d3493d6a473a2dbb1b65a59be33a249242d3 ./internal/graph/store_test.go 4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea ./internal/ingest/agent.go 05bdcd8f82756af028807a9ba37e64232e2d93b6a803468d0a29665bbdb3067f ./internal/ingest/glpikb.go ff44d56d56b9e301fbcf0f028d1bae6f0b851665f4fee65222097f1a2450f24a ./internal/ingest/glpikb_test.go 257a4beba480dab7d9b79c1f32496f6b4f648c4c05170f51a77220dbfd21fe96 ./internal/ingest/knowledge.go a13910fb417484d56e78ae856b71fb66c63987d9abf3b513190bc52697321a18 ./internal/ingest/knowledge_test.go -835f6913829df7d55560d62dfe39dc01d0448dba63dae808249e32bd130b51d7 ./internal/model/model.go +8be0d79e9c35ba2f8ccd8725ae1f9bd921009225be69bd74dd5372128b7bcb0f ./internal/model/model.go db7a99b832bc41c61584cd726d5fc7f4f250197d716d58bc27e715923f7f5426 ./internal/ollama/client.go 61089700aa5912b58bff526b71c2a1bda74077b802f9ff69a0d7dc89c1e3e3a1 ./internal/ollama/client_test.go -e61a9a426b96851b409ac357bdc53853324b5c5da7568ff6f89fe9cc09f83a73 ./internal/persist/coordinator.go -d33318b43388f134358cf40f5b0f130eb10edcca06011955ea0544897b4e4ad1 ./internal/persist/coordinator_test.go -2eb686cfc9016b9b0cf15e5c342f693ec9b60c454bc4814c1c9583f6d5b5b806 ./internal/research/searxng.go -6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go -1c44338935a4faeedd9671c23df235aa99ea55ed6b2e72060a5d5ddc6c7a6464 ./internal/web/server.go -4d89321b95eb2af3f594adb4d8230aa85377bebe707d232a6af7f1a1f99523dd ./internal/web/server_test.go -d6463cc4c15e023eff8488e9541aa2516386ca3d04993868206e7e0885fb2eb0 ./internal/web/static/app.css -d860d640c34d2f65c9f3d387a85988e24446e8464ad701326beae3a5a17e3be5 ./internal/web/static/app.js -7e99be904fea61b19bfd5560c93eec5dc5eee75252d1410ca1661cc4a87181dd ./internal/web/static/index.html -0fe63bd83de071d285fe3576f0ac8093a92bc1e61681649a7330680cd0660197 ./neural-brain +0bc8bb4c698c2c5dbef5c980d3e3fb88f10d35a8d7a230cbc81d218798bc1fe6 ./internal/persist/coordinator.go +46144aa719ff5c3ab787c214d8e32e4b3c6883bed068410e5403a26f6352cd6a ./internal/persist/coordinator_test.go +e7138877303bcc07c429323c4792fed112d8c16d35fd44222fe144ab0b69344f ./internal/research/fetch.go +6a9f269783a7c41d5b63f9bd5022f415ed1b5572041ca5ccae1512d6dc2a0b80 ./internal/research/fetch_test.go +a46b953c22fb2aef112b11ba65c61bda1e1582903e39489c2d081d17519ebd9d ./internal/research/searxng.go +2f82e80ad91590f419b1bbb19647b5191596e97c4d765e8736c1867ad899d457 ./internal/research/searxng_test.go +4e55706a88d1a8c9f58678a9087add98618654fd2697f990f0f6fade187a9576 ./internal/web/server.go +cd371ec2ffc178b986e7b7cb840f9d764e3d05e7f2547b1193acc8d3c8a82276 ./internal/web/server_test.go +4a0e7f3170b0a5cc682cc3e2a4337601f7e29e603b61127e5a61c674ba52bf34 ./internal/web/static/app.css +266a7243d034e92e5956b288e3d2b8fefb69bddb7ce52024971cf98486242b9c ./internal/web/static/app.js +bab60b900385858773148b939988dfd2e45ed34f8041f8cb689281e63669aefd ./internal/web/static/index.html 83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png diff --git a/VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md b/VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md new file mode 100644 index 0000000..f85c6ae --- /dev/null +++ b/VALIDATION-CONSTELLATION-ECO-TRANSITIONS.md @@ -0,0 +1,82 @@ +# Validierung: Constellation, ECO, Übergänge und Evidence-Pfad + +## Statische Prüfungen + +Erfolgreich ausgeführt: + +```text +node --check internal/web/static/app.js +YAML-Parsing von docker-compose.yml +YAML-Parsing von deployment/docker-compose.full.yml +statische Prüfung aller neuen HTML-Control-IDs +``` + +## Go-Prüfung + +Die vollständige Codebasis wurde mit einem reinen Compile-Stub für den importierten Treiber `modernc.org/sqlite` typgeprüft: + +```text +go test -run '^$' ./... +go vet ./... +``` + +Gezielt ausgeführt wurden außerdem: + +```text +TestLoadRuntimeControlDefaultsAndFilters +TestMetadataStringTreatsMissingAndNilAsEmpty +``` + +Damit sind die neuen Config-/Runtime-Felder sowie die Behandlung fehlender Evidence-Pfade abgedeckt. + +Die Ausführungsumgebung konnte `modernc.org/sqlite v1.37.1` nicht von `proxy.golang.org` laden. Deshalb wurden SQLite-Laufzeittests hier nicht erneut ausgeführt. `go.mod` und `go.sum` im Projekt bleiben unverändert und verwenden weiterhin den echten Treiber. + +## Browser-Smoke-Test + +Die echte `index.html`, `app.css` und `app.js` wurden in Chromium über das DevTools-Protokoll mit simulierten API- und SSE-Antworten ausgeführt. + +Geprüft: + +- Initialisierung der Neural-Ansicht; +- Wechsel zu Constellation; +- Ghost-Fade während des Ansichtswechsels und automatisches Aufräumen danach; +- Aktivierung des ECO-Modus einschließlich DPR-/Frameprofilwechsel; +- Entfernung eines sichtbaren Nodes mit Fade-out; +- Einfügen eines neuen Nodes mit Fade-in; +- Identität und Position eines unveränderten Nodes bleiben erhalten; +- keine JavaScript-Runtime-Exceptions, Console-Errors oder Log-Errors. + +## Synthetischer Lasttest + +Ein zusätzlicher Headless-Chromium-Test verwendete: + +```text +5.000 Nodes +10.000 Edges +20 semantische Cluster +``` + +Dabei wurden folgende Eigenschaften bestätigt: + +- ein identischer Snapshot wird ohne Layout- oder Render-Neuaufbau verworfen; +- eine reine Edge-Erweiterung führt nicht zu einer vollständigen Node-Neuplatzierung; +- Neural verwendet die bestehende LOD-Verdichtung; +- Constellation kann sämtliche gefilterten Nodes darstellen; +- ECO aktiviert feste Effektbudgets, geringere DPR, gebatchte ruhige Sterne und gedrosselte Tiefensortierung; +- keine Browser-Runtimefehler. + +Die FPS eines Headless-Browsers mit Software-Canvas sind kein belastbarer GPU-Benchmark und werden daher nicht als Produktversprechen verwendet. + +## Research-Evidence-Regression + +`metadataString` behandelt nun alle folgenden Zustände als fehlenden Pfad: + +```text +nicht vorhandener Schlüssel +nil +"" +"null" +leere Zeichenfolge +``` + +Ein valider relativer Pfad wird weiterhin getrimmt und unverändert zurückgegeben. Dadurch versucht die Engine nicht mehr, `BRAIN_DATA_DIR/` zu öffnen. diff --git a/VISUALIZATION-PERFORMANCE.md b/VISUALIZATION-PERFORMANCE.md new file mode 100644 index 0000000..539c912 --- /dev/null +++ b/VISUALIZATION-PERFORMANCE.md @@ -0,0 +1,102 @@ +# Visualisierungen, weiche Übergänge und ECO-Modus + +## Ansichten + +### Neural + +Die Neural-Ansicht bleibt die detailreichste Darstellung. Sie zeigt Cortex-Regionen, hierarchisches Level-of-Detail, Synapsen, Aktivitätspartikel, Recherchepfade und semantische Verdichtungen. + +### Honeycomb + +Honeycomb verteilt Notes auf einem stabilen, gleichmäßig beabstandeten 3D-Gitter in Gehirnform. Die Zuordnung eines Nodes zu einem Gitterplatz bleibt über Graphupdates hinweg erhalten. Neue Nodes belegen freie Plätze, statt die komplette Wabe neu anzuordnen. + +### Constellation + +Constellation ist die dritte Ansicht für große und stark verknüpfte Wissensbasen. Semantische Themen bilden ruhige Zentren; ihre Notes liegen als Sterne in stabilen Umlaufbereichen. Aggregierte Verbindungen zwischen Themen zeigen Übergänge, ohne sämtliche Einzelkanten zu zeichnen. + +Die Ansicht eignet sich besonders für: + +- Überblick über Wissensdomänen und deren Größen; +- Erkennen von thematischen Inseln und Brücken; +- Beobachten von Learning-, Thinking- und Research-Aktivität; +- große Graphen, bei denen Einzelkanten in Neural zu dicht werden. + +## ECO-Modus + +Der Schalter **ECO** ist sowohl in der unteren Steuerleiste als auch unter **FILTER → Performance** verfügbar. Die Einstellung wird in `runtime-settings.json` gespeichert. + +```env +BRAIN_LOW_POWER_MODE=false +``` + +Im ECO-Modus bleiben die charakteristischen Animationen erhalten, ihre Kosten werden jedoch begrenzt: + +- Zielintervall ungefähr 30 FPS; +- maximale Device-Pixel-Ratio 1,15 statt 2; +- weniger inaktive Synapsen, Partikel, Cortex-Wolken und Hintergrundsterne; +- günstigere Halos und Recherchepakete ohne viele radiale Gradienten; +- selteneres Tiefensortieren; +- kleinere Übergangspuffer; +- weniger gleichzeitig dargestellte Constellation-Verbindungen. + +Der Modus ändert keine Graphdaten und entfernt keine Wissens-Nodes. Für eine harte Obergrenze sichtbarer Nodes bleibt `BRAIN_MAX_DISPLAY_NODES` zuständig. + +## Update- und Übergangsmodell + +Graphpolling und SSE-Events führen nicht mehr automatisch zu einem vollständigen visuellen Neuaufbau. + +- Identische Graphversion und identische Anzeigefilter werden ignoriert. +- Parallele Reload-Anforderungen werden zusammengeführt und leicht entprellt. +- Vorhandene Node-Objekte und ihre Koordinaten bleiben erhalten. +- Reine Kantenänderungen aktualisieren Links, ohne sämtliche Nodes neu zu platzieren. +- Honeycomb-Slots sind stabil und werden wiederverwendet. +- Neue Nodes erhalten einen Fade-in-Status. +- Entfernte Nodes und Edges bleiben für die Fade-out-Dauer in einem begrenzten Übergangspuffer. +- Beim Wechsel zwischen Neural, Honeycomb und Constellation werden alte Bildschirmpositionen kurz als Ghosts gehalten und Nodes zwischen den 3D-Koordinaten gemorpht. + +Standardzeiten: + +```text +Node Fade-in: 720 ms +Node/Edge Fade-out: 680 ms +Positionsmorphing: 620 ms +Ansichtswechsel: 760 ms +``` + +Die SearXNG-Animation besitzt weiterhin ihren eigenen Lebenszyklus und wird durch Graphupdates oder Ansichtswechsel nicht abgebrochen. + +## Renderoptimierungen + +Zusätzlich wurden folgende Hotpaths reduziert: + +- Kameratrigonometrie und Perspektivparameter werden einmal pro Frame berechnet; +- der statische Hintergrund wird in einem Offscreen-Canvas gecacht; +- inaktive Edges werden deterministisch bis zum Profil-Limit abgetastet; +- aktive Edges werden unabhängig vom Limit weiterhin gezeichnet; +- vorhandene Cluster und Node-Positionen werden wiederverwendet; +- bei unveränderten Clustern läuft nur eine kurze Relaxation; +- Tiefensortierung wird im ECO-Modus gedrosselt; +- Partikel- und Übergangspuffer besitzen feste Obergrenzen. + +## Runtime-API + +Beispiel: + +```json +{ + "view_mode": "constellation", + "low_power_mode": true +} +``` + +Unterstützte Ansichten: + +```text +neural +honeycomb +constellation +``` + +## Hinweis zur Research-Evidence-Korrektur + +Ältere externe Nodes können in SQLite ein fehlendes `evidence_path` als `null` beziehungsweise als Zeichenfolge `` enthalten. Diese Werte werden nun als nicht vorhandener Pfad behandelt und übersprungen. Dadurch wird nicht mehr versucht, unter Windows eine Datei wie `data\\` zu öffnen, und die wiederholten Warnmeldungen entfallen. Gültige Evidenzpfade werden unverändert weiterverwendet. diff --git a/deployment/docker-compose.full.yml b/deployment/docker-compose.full.yml index 3dff26a..ab975f6 100644 --- a/deployment/docker-compose.full.yml +++ b/deployment/docker-compose.full.yml @@ -137,6 +137,7 @@ services: BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-} BRAIN_DEFAULT_VIEW: ${BRAIN_DEFAULT_VIEW:-neural} BRAIN_MAX_DISPLAY_NODES: ${BRAIN_MAX_DISPLAY_NODES:-0} + BRAIN_LOW_POWER_MODE: ${BRAIN_LOW_POWER_MODE:-false} BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s} BRAIN_ENRICH_BATCH_SIZE: ${BRAIN_ENRICH_BATCH_SIZE:-3} BRAIN_ENRICH_STEP_DELAY: ${BRAIN_ENRICH_STEP_DELAY:-3s} diff --git a/docker-compose.yml b/docker-compose.yml index 908c1dc..e577f64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-} BRAIN_DEFAULT_VIEW: ${BRAIN_DEFAULT_VIEW:-neural} BRAIN_MAX_DISPLAY_NODES: ${BRAIN_MAX_DISPLAY_NODES:-0} + BRAIN_LOW_POWER_MODE: ${BRAIN_LOW_POWER_MODE:-false} BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s} BRAIN_ENRICH_BATCH_SIZE: ${BRAIN_ENRICH_BATCH_SIZE:-3} BRAIN_ENRICH_STEP_DELAY: ${BRAIN_ENRICH_STEP_DELAY:-3s} diff --git a/internal/config/config.go b/internal/config/config.go index 8e48612..fd854fc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,6 +70,7 @@ type Config struct { ThinkingCategories []string DefaultView string MaxDisplayNodes int + LowPowerMode bool RuntimeDefaultsConfigured bool GLPIKBEnabled bool @@ -162,6 +163,7 @@ func Load() (Config, error) { ThinkingCategories: stringList("BRAIN_THINKING_CATEGORIES"), DefaultView: strings.ToLower(env("BRAIN_DEFAULT_VIEW", "neural")), MaxDisplayNodes: integer("BRAIN_MAX_DISPLAY_NODES", 0), + LowPowerMode: boolean("BRAIN_LOW_POWER_MODE", false), RuntimeDefaultsConfigured: true, GLPIKBEnabled: boolean("GLPI_KB_ENABLED", false), GLPIURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GLPI_URL")), "/"), @@ -253,8 +255,8 @@ func Load() (Config, error) { if cfg.ResearchEnabled && cfg.SearXNGURL == "" { return Config{}, fmt.Errorf("BRAIN_RESEARCH_ENABLED requires SEARXNG_URL") } - if cfg.DefaultView != "neural" && cfg.DefaultView != "honeycomb" { - return Config{}, fmt.Errorf("BRAIN_DEFAULT_VIEW must be neural or honeycomb") + if cfg.DefaultView != "neural" && cfg.DefaultView != "honeycomb" && cfg.DefaultView != "constellation" { + return Config{}, fmt.Errorf("BRAIN_DEFAULT_VIEW must be neural, honeycomb or constellation") } if cfg.MaxDisplayNodes < 0 || cfg.MaxDisplayNodes > 500000 { return Config{}, fmt.Errorf("BRAIN_MAX_DISPLAY_NODES must be between 0 and 500000") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6c059f1..866d81f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -45,13 +45,14 @@ func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) { t.Setenv("BRAIN_LEARNING_CATEGORIES", "Netzwerk,GLPI KB") t.Setenv("BRAIN_DISPLAY_CATEGORIES", "GLPI KB") t.Setenv("BRAIN_THINKING_CATEGORIES", "Netzwerk") - t.Setenv("BRAIN_DEFAULT_VIEW", "honeycomb") + t.Setenv("BRAIN_DEFAULT_VIEW", "constellation") t.Setenv("BRAIN_MAX_DISPLAY_NODES", "12000") + t.Setenv("BRAIN_LOW_POWER_MODE", "true") cfg, err := Load() if err != nil { t.Fatal(err) } - if cfg.LearningEnabled || cfg.ThinkingEnabled || cfg.DefaultView != "honeycomb" || cfg.MaxDisplayNodes != 12000 { + if cfg.LearningEnabled || cfg.ThinkingEnabled || cfg.DefaultView != "constellation" || cfg.MaxDisplayNodes != 12000 || !cfg.LowPowerMode { t.Fatalf("unexpected runtime defaults: %+v", cfg) } if len(cfg.LearningCategories) != 2 || len(cfg.DisplayCategories) != 1 || len(cfg.ThinkingCategories) != 1 { diff --git a/internal/engine/article.go b/internal/engine/article.go index cd0171b..7d35e70 100644 --- a/internal/engine/article.go +++ b/internal/engine/article.go @@ -1457,7 +1457,15 @@ func metadataString(metadata map[string]any, key string) string { if metadata == nil { return "" } - return strings.TrimSpace(fmt.Sprint(metadata[key])) + value, ok := metadata[key] + if !ok || value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || strings.EqualFold(text, "") || strings.EqualFold(text, "null") { + return "" + } + return text } func metadataStringSlice(metadata map[string]any, key string) []string { diff --git a/internal/engine/article_research_cache_test.go b/internal/engine/article_research_cache_test.go index acc347d..a50ae76 100644 --- a/internal/engine/article_research_cache_test.go +++ b/internal/engine/article_research_cache_test.go @@ -89,3 +89,21 @@ func TestLoadResearchEvidenceCanReuseInMemoryQueuedRecord(t *testing.T) { t.Fatalf("unexpected cached evidence: %+v", loaded) } } + +func TestMetadataStringTreatsMissingAndNilAsEmpty(t *testing.T) { + cases := []map[string]any{ + nil, + {}, + {"evidence_path": nil}, + {"evidence_path": ""}, + {"evidence_path": "null"}, + } + for _, metadata := range cases { + if got := metadataString(metadata, "evidence_path"); got != "" { + t.Fatalf("expected empty metadata string, got %q from %#v", got, metadata) + } + } + if got := metadataString(map[string]any{"evidence_path": " research-evidence/x.json "}, "evidence_path"); got != "research-evidence/x.json" { + t.Fatalf("unexpected normalized metadata string %q", got) + } +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 4cbc970..9bc411c 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -392,13 +392,14 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) { LearningCategories: []string{"Netzwerk"}, DisplayCategories: []string{"GLPI KB"}, ThinkingCategories: []string{"Netzwerk"}, - ViewMode: "honeycomb", + ViewMode: "constellation", MaxDisplayNodes: 4321, + LowPowerMode: true, }) if err != nil { t.Fatal(err) } - if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" || updated.MaxDisplayNodes != 4321 { + if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "constellation" || updated.MaxDisplayNodes != 4321 || !updated.LowPowerMode { t.Fatalf("unexpected runtime settings: %+v", updated) } if e.RequestEnrich("manual") { @@ -417,7 +418,7 @@ func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) { } e2 := New(cfg, g2, activity.New(20)) loaded := e2.RuntimeSettings() - if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "honeycomb" || loaded.MaxDisplayNodes != 4321 || len(loaded.DisplayCategories) != 1 { + if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "constellation" || loaded.MaxDisplayNodes != 4321 || !loaded.LowPowerMode || len(loaded.DisplayCategories) != 1 { t.Fatalf("runtime settings were not restored: %+v", loaded) } } diff --git a/internal/engine/runtime.go b/internal/engine/runtime.go index 512f3fa..62d2e73 100644 --- a/internal/engine/runtime.go +++ b/internal/engine/runtime.go @@ -20,6 +20,7 @@ type RuntimeSettings struct { ThinkingCategories []string `json:"thinking_categories"` ViewMode string `json:"view_mode"` MaxDisplayNodes int `json:"max_display_nodes"` + LowPowerMode bool `json:"low_power_mode"` } type CategoryInfo struct { @@ -36,6 +37,7 @@ func (e *Engine) defaultRuntimeSettings() RuntimeSettings { ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...), ViewMode: e.Cfg.DefaultView, MaxDisplayNodes: e.Cfg.MaxDisplayNodes, + LowPowerMode: e.Cfg.LowPowerMode, }) } @@ -47,7 +49,7 @@ func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings { if in.ViewMode == "" { in.ViewMode = "neural" } - if in.ViewMode != "neural" && in.ViewMode != "honeycomb" { + if in.ViewMode != "neural" && in.ViewMode != "honeycomb" && in.ViewMode != "constellation" { in.ViewMode = "neural" } if in.MaxDisplayNodes < 0 { @@ -149,6 +151,7 @@ func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, "thinking_categories": len(settings.ThinkingCategories), "view_mode": settings.ViewMode, "max_display_nodes": settings.MaxDisplayNodes, + "low_power_mode": settings.LowPowerMode, }, }) return settings, nil diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 2da46a6..243d6a0 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -37,7 +37,7 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) { }, g, broker) h := (&Server{Engine: eng, Graph: g, Broker: broker}).Handler() - body := `{"learning_enabled":false,"thinking_enabled":false,"learning_categories":["GLPI"],"display_categories":["Ollama"],"thinking_categories":["GLPI"],"view_mode":"honeycomb","max_display_nodes":1500}` + body := `{"learning_enabled":false,"thinking_enabled":false,"learning_categories":["GLPI"],"display_categories":["Ollama"],"thinking_categories":["GLPI"],"view_mode":"constellation","max_display_nodes":1500,"low_power_mode":true}` req := httptest.NewRequest(http.MethodPut, "/api/runtime-settings", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() @@ -49,7 +49,7 @@ func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) { if err := json.NewDecoder(res.Body).Decode(&settings); err != nil { t.Fatal(err) } - if settings.LearningEnabled || settings.ThinkingEnabled || settings.ViewMode != "honeycomb" || settings.MaxDisplayNodes != 1500 { + if settings.LearningEnabled || settings.ThinkingEnabled || settings.ViewMode != "constellation" || settings.MaxDisplayNodes != 1500 || !settings.LowPowerMode { t.Fatalf("unexpected settings: %+v", settings) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 0f45840..71618f8 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -39,7 +39,7 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a .settings-header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:2px 2px 13px}.settings-header strong{display:block;font-size:12px;letter-spacing:.17em}.settings-header small{display:block;margin-top:4px;color:var(--muted);font-size:10px}.settings-header button{border:0;background:transparent;color:var(--muted);font-size:24px;cursor:pointer;padding:0 5px} .settings-section{padding:14px 2px;border-bottom:1px solid var(--line)}.settings-section h2{font-size:10px;letter-spacing:.17em;text-transform:uppercase;color:#9eb5c6;margin:0 0 10px}.settings-section-title{display:flex;justify-content:space-between;align-items:center}.settings-section-title span{font-size:9px;color:#668094} .switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:9px 10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022);cursor:pointer}.switch-row b{display:block;font-size:11px}.switch-row small{display:block;margin-top:3px;color:#718b9e;font-size:9px;line-height:1.35}.switch-row input{appearance:none;width:38px;height:21px;border-radius:999px;background:rgba(119,146,168,.25);position:relative;cursor:pointer;flex:0 0 auto;outline:1px solid rgba(255,255,255,.06)}.switch-row input:after{content:"";position:absolute;width:15px;height:15px;left:3px;top:3px;border-radius:50%;background:#8196a7;transition:transform .2s,background .2s,box-shadow .2s}.switch-row input:checked{background:rgba(82,231,255,.19)}.switch-row input:checked:after{transform:translateX(17px);background:var(--cyan);box-shadow:0 0 10px rgba(82,231,255,.7)} -.view-selector{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px}.view-selector button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#738da1;border-radius:10px;padding:9px;font-size:10px;font-weight:700;letter-spacing:.1em;cursor:pointer}.view-selector button.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)} +.view-selector{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:10px}.view-selector button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#738da1;border-radius:10px;padding:9px;font-size:10px;font-weight:700;letter-spacing:.1em;cursor:pointer}.view-selector button.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)} .category-settings{flex:1;min-height:0;overflow:auto;padding-right:5px}.filter-search{display:block;margin-bottom:12px}.filter-search span{display:block;font-size:9px;color:#6d879a;margin-bottom:5px}.filter-search input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.6);color:var(--text);border-radius:10px;padding:9px 10px;outline:0}.filter-search input:focus{border-color:rgba(82,231,255,.38);box-shadow:0 0 0 3px rgba(82,231,255,.06)} .filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.category-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.category-option{position:relative}.category-option input{position:absolute;opacity:0;pointer-events:none}.category-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.category-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.category-option em{font-style:normal;opacity:.58;margin-left:4px} .settings-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:13px}.settings-footer span{font-size:9px;color:#7f9aac;line-height:1.35}.settings-footer button{border:1px solid rgba(82,231,255,.3);background:rgba(82,231,255,.1);color:var(--cyan);border-radius:11px;padding:10px 13px;font-size:9px;font-weight:800;letter-spacing:.13em;cursor:pointer}.settings-footer button:disabled{opacity:.5;cursor:wait} @@ -51,3 +51,17 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a .activity-item .sources span{display:flex;align-items:flex-start;gap:6px;color:#b7d9ca;font-size:9px;line-height:1.35} .activity-item .sources i{display:inline-flex;align-items:center;justify-content:center;flex:0 0 15px;height:15px;border-radius:50%;border:1px solid rgba(93,255,189,.24);background:rgba(93,255,189,.07);color:#86ffd0;font-style:normal;font-size:8px} .research-settings{flex:0 0 auto}.research-status{margin:8px 0 10px;padding:9px 10px;border:1px solid rgba(133,200,255,.11);border-radius:11px;background:rgba(255,255,255,.022);color:#87a2b5;font-size:9px;line-height:1.45;word-break:break-word}.research-status.ok{border-color:rgba(93,255,189,.22);color:#91d9bc;background:rgba(93,255,189,.045)}.research-status.failed{border-color:rgba(255,105,125,.24);color:#ff9aab;background:rgba(255,105,125,.045)}.research-status strong{display:block;color:inherit;font-size:10px;margin-bottom:2px}.research-test-query{display:block}.research-test-query span{display:block;font-size:9px;color:#6d879a;margin-bottom:5px}.research-test-query input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.6);color:var(--text);border-radius:10px;padding:9px 10px;outline:0}.research-test-query input:focus{border-color:rgba(93,255,189,.4);box-shadow:0 0 0 3px rgba(93,255,189,.06)}.research-test-actions{display:flex;align-items:center;gap:9px;margin-top:8px}.research-test-actions button{border:1px solid rgba(93,255,189,.3);background:rgba(93,255,189,.08);color:var(--green);border-radius:10px;padding:8px 10px;font-size:8px;font-weight:800;letter-spacing:.12em;cursor:pointer}.research-test-actions button:disabled{opacity:.5;cursor:wait}.research-test-actions span{font-size:9px;color:#7893a6;line-height:1.35}.activity-item .error-detail{margin-top:7px;padding:7px 8px;border-left:2px solid rgba(255,105,125,.6);background:rgba(255,105,125,.055);color:#ff9aab;font-size:9px;line-height:1.45;white-space:pre-wrap;word-break:break-word} + +/* Constellation view, Eco mode and transition diagnostics */ +.dock #viewHoneycomb.active,.dock #viewNeural.active,.dock #viewConstellation.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)} +.dock #toggleEco.active{color:var(--amber);border-color:rgba(255,180,82,.28);background:rgba(255,180,82,.08)} +body.constellation-view .mode-status small:after{content:" · Constellation"} +body.constellation-view .legend{opacity:.78} +body.low-power #brain{image-rendering:auto} +body.low-power .vignette{opacity:.78} +body.low-power .glass{backdrop-filter:blur(12px)} +.performance-subtitle{margin-top:13px} +.eco-switch{border-color:rgba(255,180,82,.14)} +.eco-switch input:checked{background:rgba(255,180,82,.2)} +.eco-switch input:checked:after{background:var(--amber);box-shadow:0 0 10px rgba(255,180,82,.72)} +@media(max-width:620px){.view-selector{grid-template-columns:1fr}.metrics span[title]{display:none}} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 14e4c56..ba2ab87 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -26,6 +26,16 @@ 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(), @@ -41,21 +51,80 @@ 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: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural', max_display_nodes: 0}, - availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, settingsOpen: false, settingsDraft: null, + fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural', max_display_nodes: 0, low_power_mode: false}, + availableCategories: [], 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 + 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 }; + 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; - state.dpr = Math.min(devicePixelRatio || 1, 2); + 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(); @@ -195,7 +264,8 @@ const [settings, categories] = await Promise.all([api('/api/runtime-settings'), api('/api/categories')]); state.runtimeSettings = {...state.runtimeSettings, ...settings}; state.availableCategories = categories.categories || []; - state.viewMode = state.runtimeSettings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural'; + state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural'; + applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true); syncRuntimeControls(); renderCategoryFilters(); } catch { @@ -213,8 +283,10 @@ if (thinkingButton) thinkingButton.classList.toggle('active', thinking); if ($('settingsLearning')) $('settingsLearning').checked = Boolean(panelSettings.learning_enabled); if ($('settingsThinking')) $('settingsThinking').checked = Boolean(panelSettings.thinking_enabled); - if ($('settingsViewNeural')) $('settingsViewNeural').classList.toggle('active', panelSettings.view_mode !== 'honeycomb'); + if ($('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 ($('settingsMaxDisplayNodes')) $('settingsMaxDisplayNodes').value = String(Math.max(0, Number(panelSettings.max_display_nodes || 0))); updateDisplayLimitHint(panelSettings.max_display_nodes); const enrichButton = $('enrichNow'); @@ -275,15 +347,17 @@ learning_categories: [...(settings.learning_categories || [])], display_categories: [...(settings.display_categories || [])], thinking_categories: [...(settings.thinking_categories || [])], - view_mode: settings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural', - max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))) + 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) }; const previousDisplay = JSON.stringify({categories: state.runtimeSettings.display_categories || [], limit: Number(state.runtimeSettings.max_display_nodes || 0)}); const updated = await api('/api/runtime-settings', {method: 'PUT', body: JSON.stringify(normalized)}); state.runtimeSettings = {...normalized, ...updated}; - state.viewMode = state.runtimeSettings.view_mode; + const nextViewMode = state.runtimeSettings.view_mode; + applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true); syncRuntimeControls(); - applyViewMode(state.viewMode, false); + applyViewMode(nextViewMode, false); if (previousDisplay !== JSON.stringify({categories: state.runtimeSettings.display_categories || [], limit: Number(state.runtimeSettings.max_display_nodes || 0)})) { state.displaySignature = ''; await loadGraph(); @@ -308,9 +382,26 @@ $('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; @@ -318,25 +409,72 @@ const filtered = filteredSnapshot(snap); const old = state.nodeById; const oldClusters = state.clusterByKey; - state.nodes = filtered.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''})); + 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(n => [n.id, n])); - state.edgeById = new Map(state.edges.map(e => [e.id, e])); + 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 e of state.edges) { - if (!state.adjacency.has(e.source)) state.adjacency.set(e.source, []); - if (!state.adjacency.has(e.target)) state.adjacency.set(e.target, []); - state.adjacency.get(e.source).push(e); - state.adjacency.get(e.target).push(e); + 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); } - buildLayout(old, oldClusters); - for (const node of state.nodes) { - node.neuralX = node.x; - node.neuralY = node.y; - node.neuralZ = node.z; + + 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; } - buildHoneycombLayout(); - buildLODHierarchy(); + + 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); @@ -348,12 +486,33 @@ } async function loadGraph() { - try { - const snap = await api('/api/graph'); - applyGraphSnapshot(snap, false); - } catch { - setSystem('offline', false); + 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) { @@ -630,71 +789,264 @@ state.honeycombNodes = notes; if (!notes.length) { state.honeycombSpacing = 0; + state.honeySlotByID.clear(); + state.honeyPointPool = []; return; } - let low = 0.008; - let high = 0.32; - while (honeycombPointCount(low, notes.length) < notes.length && low > 0.0025) low *= 0.75; - for (let i = 0; i < 12; i++) { - const mid = (low + high) / 2; - const count = honeycombPointCount(mid, notes.length); - if (count >= notes.length) low = mid; else high = mid; + + const noteIDs = new Set(notes.map(node => node.id)); + for (const id of [...state.honeySlotByID.keys()]) { + if (!noteIDs.has(id)) state.honeySlotByID.delete(id); } - const spacing = Math.max(0.0025, low * 0.985); - let points = honeycombPointCount(spacing, Infinity, true); - if (points.length < notes.length) points = honeycombPointCount(Math.max(0.0025, spacing * 0.96), Infinity, true); - points.sort((a, b) => hashString(`${a.x.toFixed(5)}:${a.y.toFixed(5)}:${a.z.toFixed(5)}`) - hashString(`${b.x.toFixed(5)}:${b.y.toFixed(5)}:${b.z.toFixed(5)}`)); - notes.sort((a, b) => hashString(a.id) - hashString(b.id)); - const step = points.length / notes.length; - for (let i = 0; i < notes.length; i++) { - const point = points[Math.min(points.length - 1, Math.floor(i * step))]; - notes[i].honeyX = point.x; - notes[i].honeyY = point.y; - notes[i].honeyZ = point.z; + 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; } - 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() { - const neural = $('viewNeural'); - const honey = $('viewHoneycomb'); - if (neural) neural.classList.toggle('active', state.viewMode === 'neural'); - if (honey) honey.classList.toggle('active', state.viewMode === 'honeycomb'); + 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'); - for (const button of [edgeButton, cortexButton, lodButton]) { - if (button) button.disabled = state.viewMode === 'honeycomb'; - } + 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 = mode === 'honeycomb' ? 'honeycomb' : 'neural'; + 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 = 0; - state.edgeRenderMap.clear(); - for (const node of state.nodes) { - node.transitionFrom = null; - node.transitionStart = 0; + 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') { - state.renderNodes = state.honeycombNodes; - state.renderEdges = []; - state.renderIdleEdges = []; - state.renderNodeById = new Map(state.honeycombNodes.map(node => [node.id, node])); - state.renderEdgeById = new Map(); - state.visibleForNode = new Map(state.honeycombNodes.map(node => [node.id, node.id])); - state.renderStats = {nodes: state.honeycombNodes.length, edges: 0, hiddenNodes: Math.max(0, state.nodes.length - state.honeycombNodes.length), hiddenEdges: state.edges.length}; - const renderCount = $('renderCount'); - if (renderCount) renderCount.textContent = state.honeycombNodes.length.toLocaleString('de-DE'); + 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(performance.now(), true); + rebuildRenderGraph(now, true, !changed); } + + const renderCount = $('renderCount'); + if (renderCount) renderCount.textContent = state.renderStats.nodes.toLocaleString('de-DE'); updateViewButtons(); if (persist) persistRuntimeSettings().catch(() => {}); } @@ -740,7 +1092,13 @@ 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)}; + 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); @@ -762,6 +1120,7 @@ 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 = []; @@ -782,7 +1141,9 @@ seedHemis(left, -1); seedHemis(right, 1); - relaxClusters(clusterList); + 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])); @@ -803,8 +1164,8 @@ } } - function relaxClusters(clusterList) { - for (let iter = 0; iter < 70; iter++) { + 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]; @@ -855,6 +1216,13 @@ 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; @@ -866,11 +1234,9 @@ let oz = Math.cos(angleB) * shell * (0.66 + pseudo(node.id, 4) * 0.24); ox += (cluster.side === 'left' ? -1 : 1) * (0.015 + (1 - spread) * 0.018); const pos = clampBrain({x: cluster.x + ox, y: cluster.y + oy, z: cluster.z + oz}, cluster.side); - const previous = previousNodes.get(node.id); - const preserve = previous?.clusterKey === cluster.key ? 0.90 : 0; - node.x = previous ? previous.x * preserve + pos.x * (1 - preserve) : pos.x; - node.y = previous ? previous.y * preserve + pos.y * (1 - preserve) : pos.y; - node.z = previous ? previous.z * preserve + pos.z * (1 - preserve) : pos.z; + node.x = pos.x; + node.y = pos.y; + node.z = pos.z; } } } @@ -1029,7 +1395,7 @@ } } - function rebuildRenderGraph(now = performance.now(), force = false) { + 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; @@ -1095,14 +1461,16 @@ 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.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); @@ -1113,12 +1481,7 @@ 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'); @@ -1141,25 +1504,39 @@ 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 cy = Math.cos(state.yaw), sy = Math.sin(state.yaw); - const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch); - const x1 = n.x * cy - n.z * sy; - const z1 = n.x * sy + n.z * cy; - const y1 = n.y * cp - z1 * sp; - const z2 = n.y * sp + z1 * cp; + 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) { - const r = rotatePoint(n); - const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0; - const centerX = state.width / 2 + panelOffset; - const centerY = state.height / 2 - 4; - const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40)); - const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom; - const perspective = 2.9 / (3.3 - r.z * 0.42); - return {x: centerX + r.x * scale * perspective, y: centerY + r.y * scale * perspective, z: r.z, p: perspective}; + return projectCoordinates(n.x, n.y, n.z); } @@ -1206,9 +1583,10 @@ function focusCamera(node) { state.focusNodeID = node.id; state.focusClusterKey = node.clusterKey || ''; - const x = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX) ? node.honeyX : node.x; - const y = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyY) ? node.honeyY : node.y; - const z = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyZ) ? node.honeyZ : node.z; + const 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))); @@ -1241,7 +1619,7 @@ } function autonomousLivingPulse(now) { - if (state.viewMode === 'honeycomb') return; + 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]; @@ -1322,7 +1700,7 @@ } function researchSourcePoints(animation, count) { - const desired = Math.max(3, Math.min(7, count || 5)); + 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); @@ -1494,14 +1872,25 @@ 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; - 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 (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(); + } } } @@ -1538,61 +1927,83 @@ } function projectAnimatedNode(node, now) { - const honeycomb = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX); - const cluster = honeycomb ? null : node.cluster; - let x = honeycomb ? node.honeyX : node.x; - let y = honeycomb ? node.honeyY : node.y; - let z = honeycomb ? node.honeyZ : node.z; - if (node.transitionFrom && node.transitionStart) { - const t = Math.max(0, Math.min(1, (now - node.transitionStart) / 520)); + const 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 = 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; + 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; } } - if (cluster) { + + 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 + hashString(cluster.key) * 0.00001) * (learning ? 0.0025 : 0.012); + 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; } - const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw); - const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch); - const x1 = x * cy - z * sy; - const z1 = x * sy + z * cy; - const y1 = y * cp - z1 * sp; - const z2 = y * sp + z1 * cp; - const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0; - const centerX = state.width / 2 + panelOffset; - const centerY = state.height / 2 - 4; - const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40)); - const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom; - const perspective = 2.9 / (3.3 - z2 * 0.42); - return {x: centerX + x1 * scale * perspective, y: centerY + y1 * scale * perspective, z: z2, p: perspective}; + 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) { - const g = ctx.createRadialGradient(state.width * 0.56, state.height * 0.48, 24, state.width * 0.56, state.height * 0.48, Math.max(state.width, state.height) * 0.8); - g.addColorStop(0, '#071827'); - g.addColorStop(0.52, '#020711'); - g.addColorStop(1, '#010207'); - ctx.fillStyle = g; - ctx.fillRect(0, 0, state.width, state.height); + rebuildBackgroundCache(); + ctx.drawImage(state.backgroundCanvas, 0, 0, state.width, state.height); + const movingStars = state.lowPowerMode ? 8 : 22; ctx.save(); - ctx.globalAlpha = 0.16; - for (let i = 0; i < 90; i++) { - const x = (i * 193.7 + now * 0.002 * (i % 3 + 1)) % state.width; - const y = (i * 97.3) % state.height; - ctx.fillStyle = i % 9 === 0 ? '#52e7ff' : '#5d7890'; - const size = i % 11 === 0 ? 1.5 : 0.8; - ctx.fillRect(x, y, size, size); + ctx.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(); } @@ -1624,15 +2035,18 @@ } function renderClusterClouds(now) { - if (state.viewMode === 'honeycomb') return; + if (state.viewMode !== 'neural') return; ctx.save(); ctx.globalCompositeOperation = 'screen'; - for (const cluster of state.clusters) { + 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 + hashString(cluster.key) * 0.00001) * 0.035; + 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); @@ -1657,8 +2071,105 @@ 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 === 'honeycomb') return; + 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)) @@ -1697,25 +2208,25 @@ ctx.restore(); } - function renderEdges() { - if (state.viewMode === 'honeycomb') return; - if (!state.edgesVisible) return; + function renderEdges(now) { + if (state.viewMode !== 'neural' || !state.edgesVisible) return; ctx.save(); ctx.globalCompositeOperation = 'screen'; - const edges = [...state.renderIdleEdges]; - const included = new Set(edges.map(edge => edge.id)); - for (const id of state.renderEdgeActive.keys()) { - const edge = state.renderEdgeById.get(id); - if (edge && !included.has(id)) { edges.push(edge); included.add(id); } - } - for (const edge of edges) { - const a = state.renderNodeById.get(edge.source), b = state.renderNodeById.get(edge.target); - if (!a?.screen || !b?.screen) continue; - const active = state.renderEdgeActive.get(edge.id) || 0; + const 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); - if (alpha < 0.01) continue; + 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(); @@ -1724,12 +2235,29 @@ 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 === 'honeycomb') return; + 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--) { @@ -1765,14 +2293,27 @@ y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18; } const size = 8 * (particle.size || 1) * (0.82 + state.activityEnergy * 0.28); - const grad = ctx.createRadialGradient(x, y, 0, x, y, size); - grad.addColorStop(0, 'rgba(255,255,255,.95)'); - grad.addColorStop(0.22, particle.color); - grad.addColorStop(1, 'rgba(82,231,255,0)'); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(x, y, size, 0, Math.PI * 2); - ctx.fill(); + 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(); } @@ -1786,65 +2327,126 @@ const next = Math.max(0, value - dt * 0.44); if (next > 0) state.renderActive.set(id, next); else state.renderActive.delete(id); } + pruneRetiringEntities(now); - state.projected = []; + const candidates = []; for (const node of state.renderNodes) { + node._renderKey = `live:${node.id}`; node.screen = projectAnimatedNode(node, now); - state.projected.push(node); + 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; } - state.projected.sort((a, b) => a.screen.z - b.screen.z); 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 act = state.renderActive.get(node.id) || (isGroup ? 0 : state.active.get(node.id) || 0); - const hover = state.hover?.id === node.id; - const selected = state.selected?.id === node.id; - const degree = honeycomb ? 0 : Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length); + const 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 : isGroup ? memberScale : Math.max(0.25, Math.min(2.5, (node.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05)); - const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + node.x * 7 + node.y * 9); - const clusterEnergy = honeycomb ? 0 : state.clusterActive.get(node.clusterKey) || 0; - const focused = honeycomb ? 0 : node.clusterKey === state.focusClusterKey ? state.activityEnergy : 0; + const 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 : (isGroup ? 0.42 : 0.17); - const alpha = Math.min(1, baseAlpha - idleDim + node.screen.p * (honeycomb ? 0.035 : 0.1) + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0)); - if (act > 0.05 || hover || selected || (!honeycomb && (node.kind === 'ai-think' || isGroup))) { + const 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); - const halo = ctx.createRadialGradient(node.screen.x, node.screen.y, 0, node.screen.x, node.screen.y, haloRadius); - halo.addColorStop(0, nodeColor(node, (isGroup ? 0.28 : 0.48) + act * 0.35)); - halo.addColorStop(0.24, nodeColor(node, (isGroup ? 0.09 : 0.14) + act * 0.2)); - halo.addColorStop(1, nodeColor(node, 0)); - ctx.fillStyle = halo; - ctx.beginPath(); - ctx.arc(node.screen.x, node.screen.y, haloRadius, 0, Math.PI * 2); - ctx.fill(); + 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); + 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 (node.memberCount >= 8 && r > 5) { + 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,.82)'; + 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 (!honeycomb && node.kind === 'ai-think') { - ctx.strokeStyle = nodeColor(node, 0.55); + } 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); @@ -1857,10 +2459,13 @@ 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 !== 'honeycomb' && !isGroup && node.kind === 'ai-think' && node.screen.p > 1.02))) continue; + 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; @@ -1868,6 +2473,7 @@ 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(); } @@ -1917,24 +2523,54 @@ 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 dt = Math.min(0.05, (now - state.last) / 1000); + 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(); + 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; @@ -1949,6 +2585,7 @@ } requestAnimationFrame(frame); } + requestAnimationFrame(frame); function activate(evt) { @@ -1981,7 +2618,8 @@ 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)); + 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}); } @@ -2004,7 +2642,7 @@ } } addLog(evt); - if (evt.type === 'graph.updated') loadGraph(); + if (evt.type === 'graph.updated') scheduleGraphLoad(180); if (evt.type?.startsWith('think.') || evt.type?.startsWith('article.') || evt.type?.startsWith('research.')) loadStatus(); } @@ -2187,6 +2825,7 @@ $('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; @@ -2245,21 +2884,21 @@ $('categorySearch').addEventListener('input', e => renderCategoryFilters(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; }); + $('settingsLowPower').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.low_power_mode = e.currentTarget.checked; }); $('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); }); - $('settingsViewNeural').addEventListener('click', () => { - if (state.settingsDraft) state.settingsDraft.view_mode = 'neural'; - $('settingsViewNeural').classList.add('active'); - $('settingsViewHoneycomb').classList.remove('active'); - }); - $('settingsViewHoneycomb').addEventListener('click', () => { - if (state.settingsDraft) state.settingsDraft.view_mode = 'honeycomb'; - $('settingsViewHoneycomb').classList.add('active'); - $('settingsViewNeural').classList.remove('active'); - }); + 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-category-filter]'); if (!input || !state.settingsDraft) return; @@ -2292,6 +2931,7 @@ try { state.settingsDraft.learning_enabled = $('settingsLearning').checked; state.settingsDraft.thinking_enabled = $('settingsThinking').checked; + state.settingsDraft.low_power_mode = $('settingsLowPower').checked; state.settingsDraft.max_display_nodes = Math.max(0, Math.min(500000, Math.trunc(Number($('settingsMaxDisplayNodes').value) || 0))); await persistRuntimeSettings(state.settingsDraft); feedback.textContent = 'Aktiv · Speicherung erfolgt gebündelt mit dem nächsten Flush.'; @@ -2303,6 +2943,24 @@ } }); + $('toggleEco').addEventListener('click', async e => { + const button = e.currentTarget; + button.disabled = true; + const previous = Boolean(state.runtimeSettings.low_power_mode); + state.runtimeSettings.low_power_mode = !previous; + applyPerformanceMode(state.runtimeSettings.low_power_mode, true); + try { + await persistRuntimeSettings(); + } catch (err) { + state.runtimeSettings.low_power_mode = previous; + applyPerformanceMode(previous, true); + addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Eco-Modus konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()}); + } finally { + button.disabled = false; + syncRuntimeControls(); + } + }); + $('toggleLabels').addEventListener('click', e => { state.labels = !state.labels; e.currentTarget.classList.toggle('active', state.labels); @@ -2372,7 +3030,7 @@ } let best = null, dist = 14; for (const n of state.projected) { - if (!n.screen) continue; + 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; diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 315e883..251469b 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -26,6 +26,7 @@ 0 Nodes 0 Edges 0 Render + 0 FPS 0 Impulse verbindet @@ -47,6 +48,7 @@