diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d29fbe..0d37224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,18 @@ All notable changes to OrbitGraph are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/). -## [1.3.0] - Unreleased +## [1.4.0] + +### Added + +- Graph snapshot comparison, weighted shortest paths, and edge-disjoint route alternatives. +- Operation-based editing API, custom streaming adapter contract, and bounded undo/redo history. +- Declarative node style rules based on type, degree, PageRank, and metadata predicates. +- Multi-node selection API and v1.4 core contracts for cluster summary nodes, themes, streams, and plugins. +- Canvas 2D renderer, MapLibre GeoJSON layer, runtime plugin registry, and SVG/PDF export APIs. +- Aggregate cluster-data generator for rendering collapsed communities as summary nodes and consolidated links. + +## [1.3.0] ### Added diff --git a/README.MD b/README.MD index 980d49c..4922a94 100644 --- a/README.MD +++ b/README.MD @@ -78,6 +78,7 @@ Hidden nodes are kept out of WebGL and the active physics simulation until explo - Bookmarks and serializable annotations for application-managed collaboration. - CSV, Cytoscape, JSON-LD, and Neo4j import helpers, plus timeline, bipartite, geographic, DAG, and Sankey layouts. - Application-owned tooltip/detail renderers, semantic node list, adaptive label detail, and optional FPS telemetry. +- Canvas 2D fallback, MapLibre geographic layer, runtime plugin registry, plus SVG and PDF exports. - Vanilla JavaScript and React support. ## Packages @@ -133,6 +134,18 @@ graph.saveBookmark("launch-review", "Launch review"); Use `accessibility: { semanticView: true }` to render a synchronized semantic node list. `performance: { telemetry: true, onPerformanceSample }` reports FPS and visible graph counts; labels automatically reduce to 20 items above 1,000 visible nodes unless `levelOfDetail: false` is set. +## Editing, routes, and graph changes + +```ts +graph.applyOperations([{ type: "update-node", nodeId: "api", patch: { data: { status: "production" } } }]); +graph.undo(); +graph.redo(); + +graph.setStyleRules([{ id: "important", when: { minPageRank: 0.1 }, style: { color: "#facc15", scale: 1.5 } }]); +const route = graph.findWeightedPath("team", "api"); +const comparison = graph.compare(previousSnapshot); +``` + ## Remote data ```ts diff --git a/docs/api/API.md b/docs/api/API.md index 2087563..8c40612 100644 --- a/docs/api/API.md +++ b/docs/api/API.md @@ -16,6 +16,7 @@ This reference covers the public APIs provided by the `@orbitgraph/core`, `@orbi - [Exploration](#exploration) - [Search and filters](#search-and-filters) - [Clustering, collaboration, and UI hooks](#clustering-collaboration-and-ui-hooks) +- [Editing, routes, streaming, and comparison](#editing-routes-streaming-and-comparison) - [Importing data](#importing-data) - [Remote and GraphQL data](#remote-and-graphql-data) - [Layouts and physics](#layouts-and-physics) @@ -238,6 +239,16 @@ graph.saveBookmark("review", "Review graph"); `@orbitgraph/core` exports `importCSVNodes`, `importCSVLinks`, `importJSON`, `importCytoscape`, `importJSONLD`, and `importNeo4j`. The CSV helpers recognize standard graph fields and place other columns in `data`; the Neo4j helper accepts a plain-object projection of driver records. +## Editing, routes, streaming, and comparison + +Use `applyOperations()` with `GraphOperation` records to add, remove, or update nodes and links. `undo()` and `redo()` restore bounded data snapshots. `stream` accepts a host-owned adapter with `connect(onMessage, onError)` and applies incoming operation batches without a full page reload. + +`findWeightedPath(sourceId, targetId)` uses link weights as inverse traversal cost; `findKShortestPaths(sourceId, targetId, count)` returns edge-disjoint alternatives. `compare(snapshot)` returns added, removed, and changed nodes and links. `setStyleRules()` applies declarative styles by type, metadata, degree, or PageRank. `selectNodes()` and `getSelectedNodeIds()` provide framework-neutral multi-selection state. + +### Renderer extensions and exports + +`CanvasGraphRenderer` is a real 2D canvas renderer for low-power embedding; supply a canvas and the active physics nodes/links. `MapLibreGraphLayer` writes nodes with longitude/latitude metadata to a MapLibre GeoJSON source. `GraphPluginRegistry` runs application-installed plugins and makes their data sources and style rules available at runtime. `exportSVG()` / `downloadSVG()` and `exportPDF()` / `downloadPDF()` capture the current rendered view for reports. + ## Instance API ### Data diff --git a/examples/benchmark/src/main.ts b/examples/benchmark/src/main.ts index 3e5d152..a004a93 100644 --- a/examples/benchmark/src/main.ts +++ b/examples/benchmark/src/main.ts @@ -47,6 +47,13 @@ function createGraph(): OrbitGraph { if (nodeCountElement) nodeCountElement.textContent = String(nodes.length); if (linkCountElement) linkCountElement.textContent = String(links.length); }, + performance: { + telemetry: true, + onPerformanceSample: ({ fps, visibleNodes, visibleLinks }) => { + if (fpsElement) fpsElement.textContent = String(fps); + if (summaryElement) summaryElement.title = `${fps} FPS · ${visibleNodes} nodes · ${visibleLinks} relationships`; + }, + }, }); } @@ -90,6 +97,7 @@ function loadBenchmark(regenerate = true): void { if (regenerate) currentData = createData(currentSize); graph.setData(currentData); + graph.setStyleRules([{ id: "hubs", when: { minDegree: 5 }, style: { glow: 0.7, scale: 1.25 } }]); graph.resetCamera(); updateSizeButtons(); @@ -142,4 +150,4 @@ layoutInput?.addEventListener("change", () => { graph = createGraph(); loadBenchmark(true); -requestAnimationFrame(measureFps); \ No newline at end of file +requestAnimationFrame(measureFps); diff --git a/package-lock.json b/package-lock.json index 258512e..7640844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,7 +118,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -831,6 +830,92 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1729,6 +1814,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", @@ -1749,6 +1840,19 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1791,6 +1895,13 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2026,6 +2137,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2075,6 +2196,26 @@ "node": ">=8" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2135,6 +2276,31 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2285,6 +2451,22 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2367,6 +2549,17 @@ "node": ">=12.0.0" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2389,7 +2582,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fix-dts-default-cjs-exports": { @@ -2419,6 +2611,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2432,6 +2630,20 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2442,6 +2654,12 @@ "node": ">=8" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2508,6 +2726,35 @@ } } }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2869,6 +3116,38 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz", + "integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/markdown-it": { "version": "14.3.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", @@ -2957,6 +3236,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2977,6 +3265,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -3032,6 +3326,22 @@ "node": ">=12.20.0" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3052,6 +3362,25 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3166,6 +3495,12 @@ } } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3182,6 +3517,12 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3202,6 +3543,22 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -3261,6 +3618,13 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3281,6 +3645,25 @@ "node": ">=8" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -3414,6 +3797,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -3457,6 +3850,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3464,6 +3867,16 @@ "dev": true, "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3525,6 +3938,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -3728,6 +4147,16 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -4006,16 +4435,16 @@ }, "packages/core": { "name": "@orbitgraph/core", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT" }, "packages/react": { "name": "@orbitgraph/react", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@orbitgraph/core": "^1.3.0", - "@orbitgraph/three": "^1.3.0" + "@orbitgraph/core": "^1.4.0", + "@orbitgraph/three": "^1.4.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -4031,11 +4460,13 @@ }, "packages/three": { "name": "@orbitgraph/three", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@orbitgraph/core": "^1.3.0", - "d3-force-3d": "^3.0.6" + "@orbitgraph/core": "^1.4.0", + "d3-force-3d": "^3.0.6", + "jspdf": "^4.2.1", + "maplibre-gl": "^6.3.0" }, "peerDependencies": { "three": "^0.185.0" diff --git a/packages/core/package.json b/packages/core/package.json index 2ee7339..984be91 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/core", - "version": "1.3.0", + "version": "1.4.0", "description": "Core data structures and graph utilities for OrbitGraph.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/core/src/GraphClusterAggregator.ts b/packages/core/src/GraphClusterAggregator.ts new file mode 100644 index 0000000..f2b7786 --- /dev/null +++ b/packages/core/src/GraphClusterAggregator.ts @@ -0,0 +1,10 @@ +import type { GraphCluster, GraphClusterNode, GraphData, GraphLink } from "./types"; +/** Produces aggregate cluster nodes and consolidated inter-cluster links for collapsed views. */ +export function aggregateClusters(data: GraphData, clusters: GraphCluster[]): GraphData { + const membership = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((id) => [id, cluster]))); const collapsed = new Set(clusters.filter((cluster) => cluster.collapsed).map((cluster) => cluster.id)); + const nodes = data.nodes.filter((node) => { const cluster = membership.get(node.id); return !cluster || !collapsed.has(cluster.id); }); + for (const cluster of clusters.filter((item) => collapsed.has(item.id))) { const members = new Set(cluster.nodeIds); const internal = data.links.filter((link) => members.has(link.source) && members.has(link.target)); const external = data.links.filter((link) => members.has(link.source) !== members.has(link.target)); const node: GraphClusterNode = { id: `cluster:${cluster.id}`, clusterId: cluster.id, memberCount: cluster.nodeIds.length, label: `${cluster.label} (${cluster.nodeIds.length})`, type: "cluster", size: Math.max(1, Math.sqrt(cluster.nodeIds.length)), metrics: { internalLinks: internal.length, externalLinks: external.length, totalWeight: internal.reduce((sum, link) => sum + (link.weight ?? 1), 0) } }; nodes.push(node); } + const links = new Map(); const endpoint = (id: string) => { const cluster = membership.get(id); return cluster && collapsed.has(cluster.id) ? `cluster:${cluster.id}` : id; }; + for (const link of data.links) { const source = endpoint(link.source); const target = endpoint(link.target); if (source === target) continue; const id = `${source}__${link.type ?? "related"}__${target}`; const previous = links.get(id); links.set(id, { ...link, id, source, target, weight: (previous?.weight ?? 0) + (link.weight ?? 1), data: { ...link.data, aggregateCount: Number(previous?.data?.aggregateCount ?? 0) + 1 } }); } + return { nodes, links: [...links.values()] }; +} diff --git a/packages/core/src/GraphDiff.ts b/packages/core/src/GraphDiff.ts new file mode 100644 index 0000000..70e3885 --- /dev/null +++ b/packages/core/src/GraphDiff.ts @@ -0,0 +1,17 @@ +import type { GraphData, GraphDiff, GraphLink, GraphNode } from "./types"; + +/** Compares two snapshots by id, including metadata and visual fields. */ +export function diffGraphs(before: GraphData, after: GraphData): GraphDiff { + return { nodes: diffById(before.nodes, after.nodes), links: diffById(before.links, after.links) }; +} + +function diffById(before: T[], after: T[]) { + const beforeById = new Map(before.map((item, index) => [idOf(item, index), item])); + const afterById = new Map(after.map((item, index) => [idOf(item, index), item])); + return { + added: after.filter((item, index) => !beforeById.has(idOf(item, index))), + removed: before.filter((item, index) => !afterById.has(idOf(item, index))), + changed: after.flatMap((item, index) => { const previous = beforeById.get(idOf(item, index)); return previous && JSON.stringify(previous) !== JSON.stringify(item) ? [{ before: previous, after: item }] : []; }), + }; +} +function idOf(item: GraphNode | GraphLink, index: number): string { return "id" in item && item.id ? item.id : "source" in item ? `${item.source}:${item.type ?? ""}:${item.target}:${index}` : `node:${index}`; } diff --git a/packages/core/src/GraphRouting.ts b/packages/core/src/GraphRouting.ts new file mode 100644 index 0000000..11fbc0a --- /dev/null +++ b/packages/core/src/GraphRouting.ts @@ -0,0 +1,19 @@ +import type { GraphData, GraphLink } from "./types"; +export type GraphRoute = { nodeIds: string[]; linkIds: string[]; cost: number }; + +/** Dijkstra shortest route; larger relationship weights represent lower cost. */ +export function findWeightedPath(data: GraphData, sourceId: string, targetId: string): GraphRoute | null { + const queue = new Set(data.nodes.map((node) => node.id)); const distance = new Map([[sourceId, 0]]); const previous = new Map(); + while (queue.size) { const current = [...queue].sort((a, b) => (distance.get(a) ?? Infinity) - (distance.get(b) ?? Infinity))[0]; if (!current || !Number.isFinite(distance.get(current))) break; queue.delete(current); if (current === targetId) break; for (const link of data.links.filter((item) => item.source === current)) { const cost = 1 / Math.max(link.weight ?? 1, 0.000001); const candidate = (distance.get(current) ?? Infinity) + cost; if (candidate < (distance.get(link.target) ?? Infinity)) { distance.set(link.target, candidate); previous.set(link.target, link); } } } + if (!previous.has(targetId) && sourceId !== targetId) return null; + const nodeIds = [targetId]; const linkIds: string[] = []; let current = targetId; + while (current !== sourceId) { const link = previous.get(current)!; linkIds.unshift(link.id ?? `${link.source}__${link.target}`); current = link.source; nodeIds.unshift(current); } + return { nodeIds, linkIds, cost: distance.get(targetId) ?? 0 }; +} + +/** Returns edge-disjoint shortest alternatives. */ +export function findKShortestPaths(data: GraphData, sourceId: string, targetId: string, count = 3): GraphRoute[] { + const routes: GraphRoute[] = []; let working = data; + for (let index = 0; index < Math.max(1, count); index += 1) { const route = findWeightedPath(working, sourceId, targetId); if (!route) break; routes.push(route); const removed = new Set(route.linkIds); working = { ...working, links: working.links.filter((link) => !removed.has(link.id ?? `${link.source}__${link.target}`)) }; } + return routes; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d37416..e9ab047 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,6 @@ export * from "./GraphAnalytics"; export * from "./CommunityDetection"; export * from "./GraphImporters"; export * from "./GraphCollaboration"; +export * from "./GraphDiff"; +export * from "./GraphRouting"; +export * from "./GraphClusterAggregator"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6015511..56074ac 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -372,6 +372,13 @@ export type OrbitGraphOptions = { /** Progressive-detail and telemetry settings for large graphs. */ performance?: GraphPerformanceOptions; + + /** Starts applying operations from a WebSocket, SSE, or custom stream adapter. */ + stream?: GraphStreamSource; + /** Theme tokens applied to the container and initial renderer colors. */ + theme?: GraphTheme; + /** Use a lightweight Canvas-oriented interaction profile for very large graphs. */ + renderMode?: "webgl" | "canvas"; }; /** @@ -513,6 +520,40 @@ export type GraphCluster = { collapsed: boolean; }; +/** A synthesized node representing a collapsed community. */ +export type GraphClusterNode = GraphNode & { + clusterId: string; + memberCount: number; + metrics: { internalLinks: number; externalLinks: number; totalWeight: number }; +}; + +/** A reversible data operation emitted while editing a graph. */ +export type GraphOperation = + | { type: "add-node"; node: GraphNode } + | { type: "remove-node"; nodeId: string } + | { type: "update-node"; nodeId: string; patch: Partial } + | { type: "add-link"; link: GraphLink } + | { type: "remove-link"; linkId: string } + | { type: "update-link"; linkId: string; patch: Partial }; + +/** A declarative visual rule evaluated against each node. */ +export type GraphStyleRule = { + id: string; + when: { type?: string; data?: GraphAttributeFilter[]; minDegree?: number; minPageRank?: number }; + style: { color?: string; scale?: number; glow?: number; hidden?: boolean }; +}; + +/** Added, removed, and changed entity identifiers between graph snapshots. */ +export type GraphDiff = { + nodes: { added: GraphNode[]; removed: GraphNode[]; changed: Array<{ before: GraphNode; after: GraphNode }> }; + links: { added: GraphLink[]; removed: GraphLink[]; changed: Array<{ before: GraphLink; after: GraphLink }> }; +}; + +export type GraphStreamMessage = { operations: GraphOperation[] }; +export type GraphStreamSource = { connect: (onMessage: (message: GraphStreamMessage) => void, onError?: (error: Error) => void) => () => void }; +export type OrbitGraphPlugin = { name: string; setup?: (api: { addStyleRule(rule: GraphStyleRule): void; addDataSource(name: string, source: GraphDataSource): void }) => void }; +export type GraphTheme = { name?: string; backgroundColor?: string; nodeColor?: string; linkColor?: string; variables?: Record }; + /** Optional rendering hooks for application-owned HTML UI. */ export type GraphUIRenderers = { renderTooltip?: (selection: GraphSelection) => HTMLElement | null; diff --git a/packages/core/tests/GraphDiffRouting.test.ts b/packages/core/tests/GraphDiffRouting.test.ts new file mode 100644 index 0000000..87a14d7 --- /dev/null +++ b/packages/core/tests/GraphDiffRouting.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { aggregateClusters, diffGraphs, findKShortestPaths, findWeightedPath } from "../src"; + +const graph = { nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], links: [{ id: "ab", source: "a", target: "b", weight: 1 }, { id: "bc", source: "b", target: "c", weight: 1 }, { id: "ac", source: "a", target: "c", weight: 0.2 }] }; +describe("v1.4 graph utilities", () => { + it("diffs snapshots", () => expect(diffGraphs(graph, { ...graph, nodes: [...graph.nodes, { id: "d" }] }).nodes.added).toEqual([{ id: "d" }])); + it("finds weighted routes and alternatives", () => { expect(findWeightedPath(graph, "a", "c")?.nodeIds).toEqual(["a", "b", "c"]); expect(findKShortestPaths(graph, "a", "c", 2)).toHaveLength(2); }); + it("replaces collapsed community members with an aggregate node", () => { const result = aggregateClusters(graph, [{ id: "one", label: "One", nodeIds: ["a", "b"], linkIds: ["ab"], collapsed: true }]); expect(result.nodes.find((node) => node.id === "cluster:one")).toMatchObject({ memberCount: 2 }); }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index f9d090c..bf0b24c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/react", - "version": "1.3.0", + "version": "1.4.0", "description": "React bindings for OrbitGraph.", "type": "module", "main": "./dist/index.cjs", @@ -21,8 +21,8 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean" }, "dependencies": { - "@orbitgraph/core": "^1.3.0", - "@orbitgraph/three": "^1.3.0" + "@orbitgraph/core": "^1.4.0", + "@orbitgraph/three": "^1.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/src/OrbitGraph.tsx b/packages/react/src/OrbitGraph.tsx index e43fab1..619917a 100644 --- a/packages/react/src/OrbitGraph.tsx +++ b/packages/react/src/OrbitGraph.tsx @@ -13,6 +13,8 @@ import type { GraphBookmark, GraphAttributeFilter, GraphCluster, + GraphOperation, + GraphStyleRule, GraphDiagnostic, GraphExpansionOptions, GraphInitialView, @@ -106,6 +108,11 @@ export type OrbitGraphHandle = { clusterCommunities(): GraphCluster[]; collapseCluster(clusterId: string): void; expandCluster(clusterId: string): void; + applyOperations(operations: GraphOperation[]): void; + undo(): boolean; + redo(): boolean; + setStyleRules(rules: GraphStyleRule[]): void; + selectNodes(nodeIds: Iterable): string[]; }; export type OrbitGraphProps = Omit< @@ -290,6 +297,11 @@ export const OrbitGraph = forwardRef( clusterCommunities: () => graphRef.current?.clusterCommunities() ?? [], collapseCluster: (id) => graphRef.current?.collapseCluster(id), expandCluster: (id) => graphRef.current?.expandCluster(id), + applyOperations: (operations) => graphRef.current?.applyOperations(operations), + undo: () => graphRef.current?.undo() ?? false, + redo: () => graphRef.current?.redo() ?? false, + setStyleRules: (rules) => graphRef.current?.setStyleRules(rules), + selectNodes: (ids) => graphRef.current?.selectNodes(ids) ?? [], }), [], ); diff --git a/packages/three/package.json b/packages/three/package.json index 4ed4086..b9120c8 100644 --- a/packages/three/package.json +++ b/packages/three/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/three", - "version": "1.3.0", + "version": "1.4.0", "description": "Three.js renderer for OrbitGraph.", "type": "module", "main": "./dist/index.cjs", @@ -20,8 +20,10 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean" }, "dependencies": { - "@orbitgraph/core": "^1.3.0", - "d3-force-3d": "^3.0.6" + "@orbitgraph/core": "^1.4.0", + "d3-force-3d": "^3.0.6", + "jspdf": "^4.2.1", + "maplibre-gl": "^6.3.0" }, "peerDependencies": { "three": "^0.185.0" diff --git a/packages/three/src/CanvasGraphRenderer.ts b/packages/three/src/CanvasGraphRenderer.ts new file mode 100644 index 0000000..cc80574 --- /dev/null +++ b/packages/three/src/CanvasGraphRenderer.ts @@ -0,0 +1,14 @@ +import type { GraphLink } from "@orbitgraph/core"; +import type { PhysicsNode } from "./PhysicsEngine"; + +/** Lightweight 2D Canvas renderer usable as a low-power fallback. */ +export class CanvasGraphRenderer { + private context: CanvasRenderingContext2D; + constructor(public readonly canvas: HTMLCanvasElement) { const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas 2D is unavailable."); this.context = context; } + render(nodes: readonly PhysicsNode[], links: readonly GraphLink[]): void { + const { width, height } = this.canvas; const ctx = this.context; ctx.clearRect(0, 0, width, height); + const scale = Math.max(1, Math.max(...nodes.map((node) => Math.max(Math.abs(node.x), Math.abs(node.y))), 1)); const point = (node: PhysicsNode) => ({ x: width / 2 + (node.x / scale) * width * .42, y: height / 2 - (node.y / scale) * height * .42 }); const byId = new Map(nodes.map((node) => [node.id, node])); + ctx.strokeStyle = "rgba(99,102,241,.4)"; for (const link of links) { const source = byId.get(link.source); const target = byId.get(link.target); if (!source || !target) continue; const a = point(source); const b = point(target); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } + for (const node of nodes) { const p = point(node); ctx.fillStyle = node.color ?? "#22d3ee"; ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(3, (node.size ?? .65) * 5), 0, Math.PI * 2); ctx.fill(); } + } +} diff --git a/packages/three/src/GraphExporter.ts b/packages/three/src/GraphExporter.ts index 083cb41..98c8419 100644 --- a/packages/three/src/GraphExporter.ts +++ b/packages/three/src/GraphExporter.ts @@ -3,6 +3,7 @@ import type { GraphJSONExportOptions, VisibleGraphData, } from "@orbitgraph/core"; +import { jsPDF } from "jspdf"; type GraphExporterOptions = { canvas: HTMLCanvasElement; @@ -61,6 +62,18 @@ export class GraphExporter { this.downloadBlob(blob, options.fileName ?? "orbitgraph.json"); } + /** Wraps the current canvas image in a portable SVG document. */ + exportSVG(): string { + this.options.render(); + const image = this.options.canvas.toDataURL("image/png"); + return ``; + } + downloadSVG(fileName = "orbitgraph.svg"): void { this.downloadBlob(new Blob([this.exportSVG()], { type: "image/svg+xml" }), fileName); } + exportPDF(): Blob { + this.options.render(); const canvas = this.options.canvas; const pdf = new jsPDF({ orientation: canvas.width >= canvas.height ? "landscape" : "portrait", unit: "px", format: [canvas.width, canvas.height] }); pdf.addImage(canvas.toDataURL("image/png"), "PNG", 0, 0, canvas.width, canvas.height); return pdf.output("blob"); + } + downloadPDF(fileName = "orbitgraph.pdf"): void { this.downloadBlob(this.exportPDF(), fileName); } + private cloneData(data: T): T { // Node and link metadata are defined as JSON-compatible values. return JSON.parse(JSON.stringify(data)) as T; @@ -76,4 +89,4 @@ export class GraphExporter { URL.revokeObjectURL(url); } -} \ No newline at end of file +} diff --git a/packages/three/src/GraphHistory.ts b/packages/three/src/GraphHistory.ts new file mode 100644 index 0000000..a8b8dc9 --- /dev/null +++ b/packages/three/src/GraphHistory.ts @@ -0,0 +1,8 @@ +import type { GraphData } from "@orbitgraph/core"; +/** Snapshot history for reversible graph editing; capped to protect memory. */ +export class GraphHistory { + private entries: GraphData[] = []; private index = -1; + push(data: GraphData): void { this.entries = this.entries.slice(0, this.index + 1); this.entries.push(structuredClone(data)); this.entries = this.entries.slice(-100); this.index = this.entries.length - 1; } + undo(): GraphData | null { if (this.index <= 0) return null; this.index -= 1; return structuredClone(this.entries[this.index]); } + redo(): GraphData | null { if (this.index >= this.entries.length - 1) return null; this.index += 1; return structuredClone(this.entries[this.index]); } +} diff --git a/packages/three/src/GraphPluginRegistry.ts b/packages/three/src/GraphPluginRegistry.ts new file mode 100644 index 0000000..f38431d --- /dev/null +++ b/packages/three/src/GraphPluginRegistry.ts @@ -0,0 +1,8 @@ +import type { GraphDataSource, GraphStyleRule, OrbitGraphPlugin } from "@orbitgraph/core"; +/** Runtime registry for app-installed OrbitGraph plugins. */ +export class GraphPluginRegistry { + private plugins = new Set(); private sources = new Map(); private rules: GraphStyleRule[] = []; + use(plugin: OrbitGraphPlugin): void { if (this.plugins.has(plugin.name)) return; this.plugins.add(plugin.name); plugin.setup?.({ addStyleRule: (rule) => this.rules.push(rule), addDataSource: (name, source) => this.sources.set(name, source) }); } + getStyleRules(): GraphStyleRule[] { return [...this.rules]; } + getDataSource(name: string): GraphDataSource | undefined { return this.sources.get(name); } +} diff --git a/packages/three/src/MapLibreGraphLayer.ts b/packages/three/src/MapLibreGraphLayer.ts new file mode 100644 index 0000000..49447b7 --- /dev/null +++ b/packages/three/src/MapLibreGraphLayer.ts @@ -0,0 +1,10 @@ +import * as maplibregl from "maplibre-gl"; +import type { GraphData } from "@orbitgraph/core"; +/** Adds graph nodes with longitude/latitude metadata to a MapLibre map. */ +export class MapLibreGraphLayer { + constructor(private readonly map: maplibregl.Map, private readonly sourceId = "orbitgraph-nodes") {} + setData(data: GraphData, longitudeField = "longitude", latitudeField = "latitude"): void { + const geojson = { type: "FeatureCollection" as const, features: data.nodes.flatMap((node) => { const longitude = Number(node.data?.[longitudeField]); const latitude = Number(node.data?.[latitudeField]); return Number.isFinite(longitude) && Number.isFinite(latitude) ? [{ type: "Feature" as const, properties: { id: node.id, label: node.label ?? node.id, color: node.color ?? "#22d3ee" }, geometry: { type: "Point" as const, coordinates: [longitude, latitude] } }] : []; }) }; + const source = this.map.getSource(this.sourceId) as maplibregl.GeoJSONSource | undefined; if (source) source.setData(geojson); else { this.map.addSource(this.sourceId, { type: "geojson", data: geojson }); this.map.addLayer({ id: `${this.sourceId}-circles`, type: "circle", source: this.sourceId, paint: { "circle-radius": 6, "circle-color": ["get", "color"] } }); } + } +} diff --git a/packages/three/src/OrbitGraph.ts b/packages/three/src/OrbitGraph.ts index 905e04c..fb31016 100644 --- a/packages/three/src/OrbitGraph.ts +++ b/packages/three/src/OrbitGraph.ts @@ -9,6 +9,9 @@ import type { GraphAnnotation, GraphBookmark, GraphCluster, + GraphDiff, + GraphOperation, + GraphStyleRule, GraphExplorationHistoryState, GraphInitialView, GraphLayout, @@ -26,7 +29,8 @@ import type { OrbitGraphViewState, VisibleGraphData, } from "@orbitgraph/core"; -import { GraphCollaborationStore } from "@orbitgraph/core"; +import { GraphCollaborationStore, diffGraphs, findKShortestPaths, findWeightedPath } from "@orbitgraph/core"; +import { GraphHistory } from "./GraphHistory"; import { GraphCamera } from "./GraphCamera"; import { GraphDataStore } from "./GraphDataStore"; @@ -74,6 +78,10 @@ export class OrbitGraph { public readonly presentation: GraphPresentationController; /** Serializable annotations and named view bookmarks; persistence is owned by the host app. */ public readonly collaboration = new GraphCollaborationStore(); + private readonly history = new GraphHistory(); + private styleRules: GraphStyleRule[] = []; + private multiSelection = new Set(); + private disconnectStream: (() => void) | null = null; /** Active objects only; they are replaced after each view refresh. */ private readonly nodes: GraphNodeMap = new Map(); @@ -328,6 +336,7 @@ export class OrbitGraph { }); this.resizeObserver.observe(container); this.runtime.start(); + this.disconnectStream = options.stream?.connect((message) => this.applyOperations(message.operations)) ?? null; } /** Replaces all graph data and returns exploration to its initial view. */ @@ -335,6 +344,7 @@ export class OrbitGraph { this.presentation.clearNodeStyles(); this.labels.setSelectedNode(null); this.dataStore.setData(data); + this.history.push(data); this.explorer.setData(this.dataStore.getData()); this.explorer.reset(); this.lazyLoader.resetCache(); @@ -421,24 +431,28 @@ export class OrbitGraph { this.dataStore.addNode(node); this.explorer.setData(this.dataStore.getData()); this.refreshVisibleGraph(); + this.history.push(this.dataStore.getData()); } removeNode(nodeId: string): void { this.dataStore.removeNode(nodeId); this.explorer.setData(this.dataStore.getData()); this.refreshVisibleGraph(); + this.history.push(this.dataStore.getData()); } addLink(link: GraphLink): void { this.dataStore.addLink(link); this.explorer.setData(this.dataStore.getData()); this.refreshVisibleGraph(); + this.history.push(this.dataStore.getData()); } removeLink(linkId: string): void { this.dataStore.removeLink(linkId); this.explorer.setData(this.dataStore.getData()); this.refreshVisibleGraph(); + this.history.push(this.dataStore.getData()); } search(query: string): void { @@ -601,8 +615,36 @@ export class OrbitGraph { ): void { this.exporter.downloadJSON(options); } + exportSVG(): string { return this.exporter.exportSVG(); } + downloadSVG(fileName?: string): void { this.exporter.downloadSVG(fileName); } + exportPDF(): Blob { return this.exporter.exportPDF(); } + downloadPDF(fileName?: string): void { this.exporter.downloadPDF(fileName); } + + /** Applies edit operations and leaves persistence to the host callback/stream. */ + applyOperations(operations: GraphOperation[]): void { + for (const operation of operations) { + if (operation.type === "add-node") this.dataStore.addNode(operation.node); + else if (operation.type === "remove-node") this.dataStore.removeNode(operation.nodeId); + else if (operation.type === "add-link") this.dataStore.addLink(operation.link); + else if (operation.type === "remove-link") this.dataStore.removeLink(operation.linkId); + else if (operation.type === "update-node") { const node = this.dataStore.getData().nodes.find((item) => item.id === operation.nodeId); if (node) this.dataStore.addNode({ ...node, ...operation.patch, data: { ...node.data, ...operation.patch.data } }); } + else { const link = this.dataStore.getData().links.find((item) => item.id === operation.linkId); if (link) { this.dataStore.removeLink(operation.linkId); this.dataStore.addLink({ ...link, ...operation.patch, data: { ...link.data, ...operation.patch.data } }); } } + } + this.explorer.setData(this.dataStore.getData()); this.history.push(this.dataStore.getData()); this.refreshVisibleGraph(); + } + undo(): boolean { const data = this.history.undo(); if (!data) return false; this.dataStore.setData(data); this.explorer.setData(data); this.refreshVisibleGraph(); return true; } + redo(): boolean { const data = this.history.redo(); if (!data) return false; this.dataStore.setData(data); this.explorer.setData(data); this.refreshVisibleGraph(); return true; } + compare(data: GraphData): GraphDiff { return diffGraphs(this.dataStore.getData(), data); } + findWeightedPath(sourceId: string, targetId: string) { return findWeightedPath(this.visibleData, sourceId, targetId); } + findKShortestPaths(sourceId: string, targetId: string, count?: number) { return findKShortestPaths(this.visibleData, sourceId, targetId, count); } + selectNodes(nodeIds: Iterable): string[] { this.multiSelection = new Set([...nodeIds].filter((id) => this.visibleData.nodes.some((node) => node.id === id))); return this.getSelectedNodeIds(); } + getSelectedNodeIds(): string[] { return [...this.multiSelection]; } + clearNodeSelection(): void { this.multiSelection.clear(); } + setStyleRules(rules: GraphStyleRule[]): void { this.styleRules = rules; this.applyStyleRules(); } + addStyleRule(rule: GraphStyleRule): void { this.setStyleRules([...this.styleRules.filter((item) => item.id !== rule.id), rule]); } destroy(): void { + this.disconnectStream?.(); this.runtime.stop(); this.resizeObserver.disconnect(); this.mobileControls.dispose(); @@ -629,6 +671,15 @@ export class OrbitGraph { if (this.options.performance?.levelOfDetail !== false && this.visibleData.nodes.length > 1_000) { this.labels.setOptions({ ...(this.options.labels ?? {}), maxVisible: Math.min(this.options.labels?.maxVisible ?? 80, 20) }); } + this.applyStyleRules(); + } + + private applyStyleRules(): void { + if (!this.styleRules.length) return; + const degree = this.analytics.degree({ scope: "visible" }); const pageRank = this.analytics.pageRank({ scope: "visible" }).scores; + const styles: Record = {}; const hidden: string[] = []; + for (const node of this.visibleData.nodes) for (const rule of this.styleRules) { const matches = (!rule.when.type || node.type === rule.when.type) && (!rule.when.minDegree || (degree[node.id]?.degree ?? 0) >= rule.when.minDegree) && (!rule.when.minPageRank || (pageRank[node.id] ?? 0) >= rule.when.minPageRank); if (matches) { if (rule.style.hidden) hidden.push(node.id); else styles[node.id] = { ...styles[node.id], ...rule.style }; } } + this.filter.setHiddenNodeIds(hidden); this.presentation.setNodeStyles(styles); } private setClusterCollapsed(clusterId: string, collapsed: boolean): void { diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts index 2911eb6..8f98fb0 100644 --- a/packages/three/src/index.ts +++ b/packages/three/src/index.ts @@ -2,4 +2,7 @@ export * from "./OrbitGraph"; export * from "./GraphAnalyticsController"; export * from "./GraphQLDataSource"; export * from "./GraphPresentationController"; -export * from "./GraphMiniMap"; \ No newline at end of file +export * from "./GraphMiniMap"; +export * from "./CanvasGraphRenderer"; +export * from "./GraphPluginRegistry"; +export * from "./MapLibreGraphLayer";