From 8e4d0da62e66bb1606a15dc9c07fbccab6227558 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 10 Sep 2025 00:37:48 +0200 Subject: [PATCH] poc: 2D graph for galaxy --- galaxy/static/bundled/galaxy/galaxy-index.js | 138 ------------------- galaxy/static/bundled/galaxy/galaxy-index.ts | 108 +++++++++++++++ galaxy/templates/galaxy/user.jinja | 4 +- package-lock.json | 59 +++++++- package.json | 2 + 5 files changed, 168 insertions(+), 143 deletions(-) delete mode 100644 galaxy/static/bundled/galaxy/galaxy-index.js create mode 100644 galaxy/static/bundled/galaxy/galaxy-index.ts diff --git a/galaxy/static/bundled/galaxy/galaxy-index.js b/galaxy/static/bundled/galaxy/galaxy-index.js deleted file mode 100644 index bd5f4134..00000000 --- a/galaxy/static/bundled/galaxy/galaxy-index.js +++ /dev/null @@ -1,138 +0,0 @@ -import { default as ForceGraph3D } from "3d-force-graph"; -import { forceX, forceY, forceZ } from "d3-force-3d"; -// biome-ignore lint/style/noNamespaceImport: This is how it should be imported -import * as Three from "three"; -import SpriteText from "three-spritetext"; - -/** - * @typedef GalaxyConfig - * @property {number} nodeId id of the current user node - * @property {string} dataUrl url to fetch the galaxy data from - **/ - -/** - * Load the galaxy of an user - * @param {GalaxyConfig} config - **/ -window.loadGalaxy = (config) => { - window.getNodeFromId = (id) => { - return Graph.graphData().nodes.find((n) => n.id === id); - }; - - window.getLinksFromNodeId = (id) => { - return Graph.graphData().links.filter( - (l) => l.source.id === id || l.target.id === id, - ); - }; - - window.focusNode = (node) => { - highlightNodes.clear(); - highlightLinks.clear(); - - hoverNode = node || null; - if (node) { - // collect neighbors and links for highlighting - for (const link of window.getLinksFromNodeId(node.id)) { - highlightLinks.add(link); - highlightNodes.add(link.source); - highlightNodes.add(link.target); - } - } - - // refresh node and link display - Graph.nodeThreeObject(Graph.nodeThreeObject()) - .linkWidth(Graph.linkWidth()) - .linkDirectionalParticles(Graph.linkDirectionalParticles()); - - // Aim at node from outside it - const distance = 42; - const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); - - const newPos = - node.x || node.y || node.z - ? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio } - : { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0) - - Graph.cameraPosition( - newPos, // new position - node, // lookAt ({ x, y, z }) - 3000, // ms transition duration - ); - }; - - const highlightNodes = new Set(); - const highlightLinks = new Set(); - let hoverNode = null; - - const grpahDiv = document.getElementById("3d-graph"); - const Graph = ForceGraph3D(); - Graph(grpahDiv); - Graph.jsonUrl(config.dataUrl) - .width( - grpahDiv.parentElement.clientWidth > 1200 - ? 1200 - : grpahDiv.parentElement.clientWidth, - ) // Not perfect at all. JS-fu master from the future, please fix this :-) - .height(1000) - .enableNodeDrag(false) // allow easier navigation - .onNodeClick((node) => { - const camera = Graph.cameraPosition(); - const distance = Math.sqrt( - (node.x - camera.x) ** 2 + (node.y - camera.y) ** 2 + (node.z - camera.z) ** 2, - ); - if (distance < 120 || highlightNodes.has(node)) { - window.focusNode(node); - } - }) - .linkWidth((link) => (highlightLinks.has(link) ? 0.4 : 0.0)) - .linkColor((link) => - highlightLinks.has(link) ? "rgba(255,160,0,1)" : "rgba(128,255,255,0.6)", - ) - .linkVisibility((link) => highlightLinks.has(link)) - .nodeVisibility((node) => highlightNodes.has(node) || node.mass > 4) - // .linkDirectionalParticles(link => highlightLinks.has(link) ? 3 : 1) // kinda buggy for now, and slows this a bit, but would be great to help visualize lanes - .linkDirectionalParticleWidth(0.2) - .linkDirectionalParticleSpeed(-0.006) - .nodeThreeObject((node) => { - const sprite = new SpriteText(node.name); - sprite.material.depthWrite = false; // make sprite background transparent - sprite.color = highlightNodes.has(node) - ? node === hoverNode - ? "rgba(200,0,0,1)" - : "rgba(255,160,0,0.8)" - : "rgba(0,255,255,0.2)"; - sprite.textHeight = 2; - sprite.center = new Three.Vector2(1.2, 0.5); - return sprite; - }) - .onEngineStop(() => { - window.focusNode(window.getNodeFromId(config.nodeId)); - Graph.onEngineStop(() => { - /* nope */ - }); // don't call ourselves in a loop while moving the focus - }); - - // Set distance between stars - Graph.d3Force("link").distance((link) => link.value); - - // Set high masses nearer the center of the galaxy - // TODO: quick and dirty strength computation, this will need tuning. - Graph.d3Force( - "positionX", - forceX().strength((node) => { - return 1 - 1 / node.mass; - }), - ); - Graph.d3Force( - "positionY", - forceY().strength((node) => { - return 1 - 1 / node.mass; - }), - ); - Graph.d3Force( - "positionZ", - forceZ().strength((node) => { - return 1 - 1 / node.mass; - }), - ); -}; diff --git a/galaxy/static/bundled/galaxy/galaxy-index.ts b/galaxy/static/bundled/galaxy/galaxy-index.ts new file mode 100644 index 00000000..121f5a2c --- /dev/null +++ b/galaxy/static/bundled/galaxy/galaxy-index.ts @@ -0,0 +1,108 @@ +import { exportToHtml } from "#core:utils/globals"; + +import cytoscape from "cytoscape"; +import d3Force, { type D3ForceLayoutOptions } from "cytoscape-d3-force"; + +cytoscape.use(d3Force); + +interface GalaxyConfig { + nodeId: number; + dataUrl: string; +} + +async function getGraphData(dataUrl: string) { + const response = await fetch(dataUrl); + if (!response.ok) { + return []; + } + + const content = await response.json(); + const nodes = content.nodes.map((node, i) => { + return { + group: "nodes", + data: { + id: node.id, + name: node.name, + mass: node.mass, + }, + }; + }); + + const edges = content.links.map((link) => { + return { + group: "edges", + data: { + id: `edge_${link.source}_${link.value}`, + source: link.source, + target: link.target, + value: link.value, + }, + }; + }); + + return { nodes: nodes, edges: edges }; +} + +exportToHtml("loadGalaxy", async (config: GalaxyConfig) => { + const graphDiv = document.getElementById("3d-graph"); + const elements = await getGraphData(config.dataUrl); + const cy = cytoscape({ + container: graphDiv, + elements: elements, + style: [ + { + selector: "node", + style: { + label: "data(name)", + "background-color": "red", + }, + }, + { + selector: ".focused", + style: { + "border-width": "5px", + "border-style": "solid", + "border-color": "black", + "target-arrow-color": "black", + "line-color": "black", + }, + }, + { + selector: "edge", + style: { + width: 0.1, + }, + }, + ], + layout: { + name: "d3-force", + animate: false, + fit: false, + ungrabifyWhileSimulating: true, + fixedAfterDragging: true, + linkId: (node) => { + return node.id; + }, + + linkDistance: (link) => { + return link?.value * 1000; + }, + + stop: () => { + // Disable user grabbing of nodes + // This has to be disabled after the simulation is done + // Otherwise the simulation can't move nodes + cy.autolock(true); + + // Center on current user node + for (const node of cy.nodes()) { + if (node.id() === `${config.nodeId}`) { + node.addClass("focused"); + cy.center(node); + break; + } + } + }, + } as D3ForceLayoutOptions, + }); +}); diff --git a/galaxy/templates/galaxy/user.jinja b/galaxy/templates/galaxy/user.jinja index f6fcc392..7647b7b2 100644 --- a/galaxy/templates/galaxy/user.jinja +++ b/galaxy/templates/galaxy/user.jinja @@ -5,14 +5,14 @@ {% endblock %} {% block additional_js %} - + {% endblock %} {% block content %} {% if object.current_star %}
-
+

Reset on {{ object.get_display_name() }}

diff --git a/package-lock.json b/package-lock.json index 4d92469f..2d40f033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "country-flag-emoji-polyfill": "^0.1.8", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", + "cytoscape-d3-force": "^1.1.4", "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.5", "easymde": "^2.19.0", @@ -46,6 +47,7 @@ "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/cytoscape-cxtmenu": "^3.4.4", + "@types/cytoscape-d3-force": "^1.0.0", "@types/cytoscape-klay": "^3.1.4", "@types/jquery": "^3.5.31", "@types/js-cookie": "^3.0.6", @@ -2873,6 +2875,16 @@ "@types/cytoscape": "*" } }, + "node_modules/@types/cytoscape-d3-force": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/cytoscape-d3-force/-/cytoscape-d3-force-1.0.0.tgz", + "integrity": "sha512-1eRd9xr/DvJ4MIA5lCEG8DMX2Ha87qAbpP7irpuKZun0ZCBQPpoOBo9mPl0WrkJbXH+hHwG8s3E2CpUz3HxLrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cytoscape": "^3.0.9" + } + }, "node_modules/@types/cytoscape-klay": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", @@ -3530,6 +3542,18 @@ "cytoscape": "^3.2.0" } }, + "node_modules/cytoscape-d3-force": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/cytoscape-d3-force/-/cytoscape-d3-force-1.1.4.tgz", + "integrity": "sha512-8NjI/yEoB3YqVsdf7ud7Oh8Kyi+C9Lhh1fICmtemIo6EC1ZUtm8KcPNLkQySYO8nRS2mQKj5eVdCr7W0L8ONoQ==", + "license": "MIT", + "dependencies": { + "d3-force": "^2.0.1" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/cytoscape-klay": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", @@ -3578,6 +3602,17 @@ "node": ">=12" } }, + "node_modules/d3-force": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz", + "integrity": "sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-quadtree": "1 - 2", + "d3-timer": "1 - 2" + } + }, "node_modules/d3-force-3d": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", @@ -3594,6 +3629,24 @@ "node": ">=12" } }, + "node_modules/d3-force/node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force/node_modules/d3-quadtree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz", + "integrity": "sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force/node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -5737,9 +5790,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index eb600f26..0532ba45 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/cytoscape-cxtmenu": "^3.4.4", + "@types/cytoscape-d3-force": "^1.0.0", "@types/cytoscape-klay": "^3.1.4", "@types/jquery": "^3.5.31", "@types/js-cookie": "^3.0.6", @@ -56,6 +57,7 @@ "country-flag-emoji-polyfill": "^0.1.8", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", + "cytoscape-d3-force": "^1.1.4", "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.5", "easymde": "^2.19.0",