From 33ceaa736cc4f0578bf508fe2c6fe542935518ed Mon Sep 17 00:00:00 2001 From: Manuel Garcia Date: Tue, 11 Aug 2026 18:53:28 -0600 Subject: [PATCH 1/2] feat: add v1.3 graph exploration tools --- CHANGELOG.md | 13 +++ README.MD | 28 ++++++ ROADMAP.md | 29 ++++-- docs/api/API.md | 32 ++++++- package-lock.json | 12 +-- packages/core/README.md | 38 +++++++- packages/core/package.json | 2 +- packages/core/src/GraphCollaboration.ts | 24 +++++ packages/core/src/GraphImporters.ts | 65 ++++++++++++++ packages/core/src/index.ts | 4 +- packages/core/src/types.ts | 89 ++++++++++++++++++- .../core/tests/GraphCollaboration.test.ts | 15 ++++ packages/core/tests/GraphImporters.test.ts | 14 +++ packages/react/README.md | 25 ++++++ packages/react/package.json | 6 +- packages/react/src/OrbitGraph.tsx | 29 +++++- packages/three/README.md | 38 +++++++- packages/three/package.json | 4 +- packages/three/src/GraphFilter.ts | 43 ++++++++- packages/three/src/GraphLayoutEngine.ts | 51 ++++++++++- packages/three/src/GraphRuntime.ts | 16 +++- packages/three/src/GraphUIController.ts | 40 +++++++++ packages/three/src/OrbitGraph.ts | 87 +++++++++++++++++- packages/three/tests/GraphFilter.test.ts | 23 ++++- .../three/tests/GraphLayoutEngine.test.ts | 12 ++- 25 files changed, 701 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/GraphCollaboration.ts create mode 100644 packages/core/src/GraphImporters.ts create mode 100644 packages/core/tests/GraphCollaboration.test.ts create mode 100644 packages/core/tests/GraphImporters.test.ts create mode 100644 packages/three/src/GraphUIController.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8aa8c7..9d29fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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 + +### Added + +- Community clustering API with visual community colors and collapsible cluster membership. +- Combined metadata predicates and inclusive link-weight range filtering. +- Serializable shared-view URLs, application-managed annotations, and named bookmarks. +- Framework-neutral tooltip and detail-panel render hooks. +- CSV, Cytoscape, JSON-LD, and Neo4j graph import helpers. +- Timeline, bipartite, geographic, DAG, and Sankey layout modes. +- Optional FPS/visible-count telemetry, adaptive label level of detail, and an accessible synchronized semantic node list. +- React ref bindings for advanced filters, shared views, annotations, and bookmarks. + ## [1.2.0] ### Added diff --git a/README.MD b/README.MD index e2a81c8..980d49c 100644 --- a/README.MD +++ b/README.MD @@ -74,6 +74,10 @@ Hidden nodes are kept out of WebGL and the active physics simulation until explo - Intelligent persistent labels, hover labels, and an interactive mini-map. - Desktop keyboard movement, keyboard graph navigation, touch controls, and mobile controls. - Node/link events, selection, diagnostics, loading state, PNG export, JSON export, and view-state persistence. +- Automatic community colors with collapsible clusters, advanced metadata filters, and shareable views. +- 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. - Vanilla JavaScript and React support. ## Packages @@ -105,6 +109,30 @@ const communities = await graph.analytics.detectCommunitiesAsync({ Analytics calculate data; they never change your graph automatically. Your application decides how results should appear. +## Collaborative exploration and large graphs + +```ts +const clusters = graph.clusterCommunities(); +graph.collapseCluster(clusters[0].id); +graph.expandCluster(clusters[0].id); + +graph.setAdvancedFilters({ + minimumLinkWeight: 0.5, + attributes: [{ field: "status", operator: "equals", value: "production" }], +}); + +const urlState = graph.shareView(); +graph.addAnnotation({ + id: "note-api", + target: { kind: "node", nodeId: "api" }, + body: "Review ownership before launch.", + createdAt: new Date().toISOString(), +}); +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. + ## Remote data ```ts diff --git a/ROADMAP.md b/ROADMAP.md index 6b15f10..14e0643 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # OrbitGraph roadmap -## Current development: v1.1.0 +## Current development: v1.3.0 - [x] Desktop, touch, and keyboard camera navigation - [x] Responsive mobile camera controls @@ -9,14 +9,25 @@ - [x] React ref API for interactions and exports - [x] Test coverage and documentation for v1.1 APIs -## Planned: v1.2.0 — scalable graph intelligence +## Delivered: v1.2.0 — scalable graph intelligence -- [ ] Move force simulation to a Web Worker -- [ ] Run expensive graph analysis outside the main UI thread -- [ ] Connected-component and community detection -- [ ] Centrality metrics such as degree, weighted degree, and PageRank -- [ ] Optional visual highlighting for communities and important nodes -- [ ] Analysis benchmarks for large graphs +- [x] Move force simulation to a Web Worker +- [x] Run expensive graph analysis outside the main UI thread +- [x] Connected-component and community detection +- [x] Centrality metrics such as degree, weighted degree, and PageRank +- [x] Optional visual highlighting for communities and important nodes +- [x] Analysis benchmarks for large graphs + +## v1.3.0 — collaborative exploration + +- [x] Advanced node metadata and relationship-weight filtering +- [x] Serializable shared views, annotations, and bookmarks +- [x] Framework-agnostic tooltip and details rendering hooks +- [x] CSV, JSON-LD, Cytoscape, and Neo4j data import helpers +- [x] Timeline, bipartite, geographic, DAG, and Sankey layout modes +- [ ] Community cluster meshes with expand/collapse animation +- [ ] Automatic level-of-detail and runtime telemetry +- [ ] Synchronized semantic table/tree view ## Future @@ -25,4 +36,4 @@ - [ ] AR/VR exploration experiments - [ ] Server-side graph transformation utilities -The roadmap describes intent, not a release contract. Feedback and contributions are welcome through GitHub issues and discussions. \ No newline at end of file +The roadmap describes intent, not a release contract. Feedback and contributions are welcome through GitHub issues and discussions. diff --git a/docs/api/API.md b/docs/api/API.md index 0d648c0..2087563 100644 --- a/docs/api/API.md +++ b/docs/api/API.md @@ -15,6 +15,8 @@ This reference covers the public APIs provided by the `@orbitgraph/core`, `@orbi - [Instance API](#instance-api) - [Exploration](#exploration) - [Search and filters](#search-and-filters) +- [Clustering, collaboration, and UI hooks](#clustering-collaboration-and-ui-hooks) +- [Importing data](#importing-data) - [Remote and GraphQL data](#remote-and-graphql-data) - [Layouts and physics](#layouts-and-physics) - [Camera, touch, and keyboard controls](#camera-touch-and-keyboard-controls) @@ -166,6 +168,8 @@ type OrbitGraphOptions = { onLoadingChange?: (state: GraphLoadingState) => void; onDiagnostic?: (diagnostic: GraphDiagnostic) => void; onKeyboardFocusChange?: (node: GraphNode | null) => void; + ui?: GraphUIRenderers; + performance?: GraphPerformanceOptions; }; ``` @@ -208,6 +212,32 @@ linkFlow: { Animated flow is optional. Disable it for the lowest rendering cost on large graphs. +## Clustering, collaboration, and UI hooks + +`graph.clusterCommunities()` detects communities in the visible graph, applies a stable color overlay, and returns `GraphCluster[]`. Call `collapseCluster(id)` to temporarily hide its members and `expandCluster(id)` to restore them. `getClusters()` returns the current state. + +```ts +graph.setAdvancedFilters({ + minimumLinkWeight: 0.4, + maximumLinkWeight: 0.9, + attributes: [{ field: "region", operator: "contains", value: "north" }], +}); + +const shared = graph.shareView(); +graph.loadSharedView(shared); +graph.saveBookmark("review", "Review graph"); +``` + +`addAnnotation`, `getAnnotations`, `removeAnnotation`, `saveBookmark`, `getBookmarks`, and `restoreBookmark` operate through the serializable in-memory collaboration store. Persist `graph.collaboration.export()` in the host application to synchronize it with a server or collaboration provider. + +`ui.renderTooltip` and `ui.renderDetails` receive the current `GraphSelection` and return an `HTMLElement` (or `null`). OrbitGraph mounts that element but leaves framework integration and styling to the application. Set `accessibility.semanticView` to render an updated semantic list of visible nodes. + +`performance.telemetry` enables approximately one-second samples through `onPerformanceSample({ fps, visibleNodes, visibleLinks })`. `levelOfDetail` is enabled by default and limits persistent labels for views over 1,000 nodes. + +## Importing data + +`@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. + ## Instance API ### Data @@ -349,7 +379,7 @@ graph.setLayout("hierarchical", { }); ``` -Supported layouts are `"force"`, `"radial"`, `"grid"`, and `"hierarchical"`. +Supported layouts are `"force"`, `"radial"`, `"grid"`, `"hierarchical"`, `"dag"`, `"sankey"`, `"timeline"`, `"bipartite"`, and `"geographic"`. DAG and Sankey reuse the directed hierarchical layering; timeline reads `layoutOptions.timeField` (default `"time"`), bipartite uses `bipartiteTypes`, and geographic reads longitude/latitude metadata fields (default `"longitude"` / `"latitude"`). ### Worker physics diff --git a/package-lock.json b/package-lock.json index 02f3b44..258512e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4006,16 +4006,16 @@ }, "packages/core": { "name": "@orbitgraph/core", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT" }, "packages/react": { "name": "@orbitgraph/react", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { - "@orbitgraph/core": "^1.2.0", - "@orbitgraph/three": "^1.2.0" + "@orbitgraph/core": "^1.3.0", + "@orbitgraph/three": "^1.3.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -4031,10 +4031,10 @@ }, "packages/three": { "name": "@orbitgraph/three", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { - "@orbitgraph/core": "^1.2.0", + "@orbitgraph/core": "^1.3.0", "d3-force-3d": "^3.0.6" }, "peerDependencies": { diff --git a/packages/core/README.md b/packages/core/README.md index 419f449..b4497b8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -2,7 +2,7 @@ Shared TypeScript contracts and graph analysis utilities for OrbitGraph. -This package is renderer-agnostic. Use it to model data, implement remote sources, calculate graph metrics, detect communities, and share OrbitGraph types across an application. +This package is renderer-agnostic. Use it to model data, import common graph formats, implement remote sources, calculate graph metrics, manage serializable collaboration state, detect communities, and share OrbitGraph types across an application. ## Install @@ -84,6 +84,41 @@ const asynchronousResult = await detectCommunitiesAsync(data, { Use `detectCommunitiesAsync()` in interactive applications because it yields between propagation passes. It returns communities and a node-to-community mapping. +## Importing data + +```ts +import { + importCSVNodes, + importCSVLinks, + importCytoscape, + importJSONLD, + importNeo4j, +} from "@orbitgraph/core"; + +const nodes = importCSVNodes("id,label,team\napi,Public API,platform"); +const links = importCSVLinks("source,target,weight\nteam,api,0.95"); +``` + +The package also exports `importJSON()` for node-link data. CSV preserves unrecognised columns in `data`; Cytoscape accepts its `{ elements }` structure; JSON-LD turns IRI-valued properties into links; and `importNeo4j()` accepts a plain-object projection of Neo4j driver records. + +## Collaboration state + +```ts +import { GraphCollaborationStore } from "@orbitgraph/core"; + +const collaboration = new GraphCollaborationStore(); +collaboration.upsertAnnotation({ + id: "review-api", + target: { kind: "node", nodeId: "api" }, + body: "Confirm ownership.", + createdAt: new Date().toISOString(), +}); + +const persisted = collaboration.export(); +``` + +`GraphCollaborationStore` holds annotations and named view bookmarks in memory. Its `export()` and `import()` methods make persistence and real-time synchronization the responsibility of the host application. + ## Remote data contracts ```ts @@ -113,6 +148,7 @@ const source: GraphDataSource = { - `GraphSelection`, click events, hover events, and `VisibleGraphData` - Layout, camera, physics, accessibility, labels, mobile-control, and mini-map options - JSON export and view-state types +- `GraphAttributeFilter`, `GraphCluster`, `GraphAnnotation`, `GraphBookmark`, UI-renderer, and performance types ## Related packages diff --git a/packages/core/package.json b/packages/core/package.json index def22bd..2ee7339 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/core", - "version": "1.2.0", + "version": "1.3.0", "description": "Core data structures and graph utilities for OrbitGraph.", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/core/src/GraphCollaboration.ts b/packages/core/src/GraphCollaboration.ts new file mode 100644 index 0000000..3c7f2d3 --- /dev/null +++ b/packages/core/src/GraphCollaboration.ts @@ -0,0 +1,24 @@ +import type { GraphAnnotation, GraphBookmark, OrbitGraphViewState } from "./types"; + +/** In-memory, serializable collaboration state. Persist it in the host app to collaborate in real time. */ +export class GraphCollaborationStore { + private annotations = new Map(); + private bookmarks = new Map(); + + upsertAnnotation(annotation: GraphAnnotation): void { this.annotations.set(annotation.id, { ...annotation }); } + removeAnnotation(id: string): void { this.annotations.delete(id); } + getAnnotations(): GraphAnnotation[] { return [...this.annotations.values()].map((item) => ({ ...item })); } + saveBookmark(bookmark: GraphBookmark): void { this.bookmarks.set(bookmark.id, { ...bookmark, view: structuredClone(bookmark.view) }); } + removeBookmark(id: string): void { this.bookmarks.delete(id); } + getBookmarks(): GraphBookmark[] { return [...this.bookmarks.values()].map((item) => ({ ...item, view: structuredClone(item.view) })); } + createBookmark(id: string, name: string, view: OrbitGraphViewState): GraphBookmark { + const bookmark = { id, name, view: structuredClone(view), createdAt: new Date().toISOString() }; + this.saveBookmark(bookmark); + return bookmark; + } + export(): { annotations: GraphAnnotation[]; bookmarks: GraphBookmark[] } { return { annotations: this.getAnnotations(), bookmarks: this.getBookmarks() }; } + import(state: { annotations?: GraphAnnotation[]; bookmarks?: GraphBookmark[] }): void { + state.annotations?.forEach((annotation) => this.upsertAnnotation(annotation)); + state.bookmarks?.forEach((bookmark) => this.saveBookmark(bookmark)); + } +} diff --git a/packages/core/src/GraphImporters.ts b/packages/core/src/GraphImporters.ts new file mode 100644 index 0000000..a5b448c --- /dev/null +++ b/packages/core/src/GraphImporters.ts @@ -0,0 +1,65 @@ +import type { GraphData, GraphLink, GraphNode, JSONValue } from "./types"; + +/** Imports the common node-link JSON shape used by Cytoscape and D3 examples. */ +export function importJSON(data: GraphData): GraphData { + return { nodes: data.nodes.map((node) => ({ ...node })), links: data.links.map((link) => ({ ...link })) }; +} + +/** Parses a CSV node list. `id`, `label`, `type`, `size`, and `color` are recognised columns. */ +export function importCSVNodes(csv: string): GraphNode[] { + const [header = "", ...rows] = csv.trim().split(/\r?\n/); + const keys = parseCSVLine(header); + return rows.filter(Boolean).map((row) => { + const record = Object.fromEntries(parseCSVLine(row).map((value, index) => [keys[index], value])); + const { id, label, type, size, color, ...data } = record; + if (!id) throw new Error("CSV nodes require an id column."); + return { id, ...(label ? { label } : {}), ...(type ? { type } : {}), ...(size ? { size: Number(size) } : {}), ...(color ? { color } : {}), data: data as Record }; + }); +} + +/** Parses a CSV link list. `source`, `target`, `id`, `type`, and `weight` are recognised columns. */ +export function importCSVLinks(csv: string): GraphLink[] { + const [header = "", ...rows] = csv.trim().split(/\r?\n/); + const keys = parseCSVLine(header); + return rows.filter(Boolean).map((row) => { + const record = Object.fromEntries(parseCSVLine(row).map((value, index) => [keys[index], value])); + const { id, source, target, type, weight, color, ...data } = record; + if (!source || !target) throw new Error("CSV links require source and target columns."); + return { ...(id ? { id } : {}), source, target, ...(type ? { type } : {}), ...(weight ? { weight: Number(weight) } : {}), ...(color ? { color } : {}), data: data as Record }; + }); +} + +/** Converts Cytoscape's `{ elements: { nodes, edges } }` format. */ +export function importCytoscape(input: { elements: { nodes: Array<{ data: Record }>; edges: Array<{ data: Record }> } }): GraphData { + return { + nodes: input.elements.nodes.map(({ data }) => ({ id: String(data.id), label: typeof data.label === "string" ? data.label : undefined, type: typeof data.type === "string" ? data.type : undefined, data })), + links: input.elements.edges.map(({ data }) => ({ id: typeof data.id === "string" ? data.id : undefined, source: String(data.source), target: String(data.target), type: typeof data.type === "string" ? data.type : undefined, data })), + }; +} + +/** Converts JSON-LD resources with `@id` values into a graph. IRI-valued properties become links. */ +export function importJSONLD(resources: Array>): GraphData { + const nodes: GraphNode[] = resources.filter((item) => typeof item["@id"] === "string").map((item) => ({ id: String(item["@id"]), label: typeof item.name === "string" ? item.name : undefined, type: typeof item["@type"] === "string" ? item["@type"] : undefined, data: item })); + const nodeIds = new Set(nodes.map((node) => node.id)); const links: GraphLink[] = []; + for (const resource of resources) { + const source = resource["@id"]; if (typeof source !== "string") continue; + for (const [type, value] of Object.entries(resource)) for (const target of (Array.isArray(value) ? value : [value])) { + if (type.startsWith("@")) continue; + const targetId = typeof target === "string" ? target : typeof target === "object" && target && "@id" in target ? String((target as Record)["@id"]) : undefined; + if (targetId && nodeIds.has(targetId)) links.push({ id: `${source}:${type}:${targetId}`, source, target: targetId, type }); + } + } + return { nodes, links }; +} + +/** Converts a plain-object projection of a Neo4j result. */ +export function importNeo4j(result: { records: Array<{ nodes?: Array<{ elementId?: string; identity?: unknown; labels?: string[]; properties?: Record }>; relationships?: Array<{ elementId?: string; identity?: unknown; startNodeElementId?: string; endNodeElementId?: string; type?: string; properties?: Record }> }> }): GraphData { + const nodes = new Map(); const links: GraphLink[] = []; + result.records.forEach((record) => record.nodes?.forEach((node) => { const id = node.elementId ?? String(node.identity); nodes.set(id, { id, label: typeof node.properties?.name === "string" ? node.properties.name : id, type: node.labels?.[0], data: node.properties }); })); + result.records.forEach((record) => record.relationships?.forEach((link) => links.push({ id: link.elementId ?? String(link.identity), source: link.startNodeElementId ?? "", target: link.endNodeElementId ?? "", type: link.type, data: link.properties }))); + return { nodes: [...nodes.values()], links: links.filter((link) => link.source && link.target) }; +} + +function parseCSVLine(line: string): string[] { + return line.match(/(?:[^,"]+|"(?:[^"]|"")*")+/g)?.map((value) => value.replace(/^"|"$/g, "").replace(/""/g, '"').trim()) ?? []; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 740c00c..1d37416 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,6 @@ export * from "./Graph"; export * from "./types"; export * from "./GraphAnalytics"; -export * from "./CommunityDetection"; \ No newline at end of file +export * from "./CommunityDetection"; +export * from "./GraphImporters"; +export * from "./GraphCollaboration"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 925e314..6015511 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -366,6 +366,12 @@ export type OrbitGraphOptions = { /** Receives non-visual diagnostics from data loading operations. */ onDiagnostic?: (diagnostic: GraphDiagnostic) => void; + + /** Application-owned DOM renderers for a tooltip and detail panel. */ + ui?: GraphUIRenderers; + + /** Progressive-detail and telemetry settings for large graphs. */ + performance?: GraphPerformanceOptions; }; /** @@ -423,7 +429,16 @@ export type VisibleGraphData = { }; /** Visual arrangement used to position active graph nodes. */ -export type GraphLayout = "force" | "radial" | "grid" | "hierarchical"; +export type GraphLayout = + | "force" + | "radial" + | "grid" + | "hierarchical" + | "dag" + | "timeline" + | "bipartite" + | "geographic" + | "sankey"; /** Additional configuration for a graph layout. */ export type GraphLayoutOptions = { @@ -435,6 +450,20 @@ export type GraphLayoutOptions = { /** Distance between layout positions. @defaultValue 12 or 14 depending on layout. */ spacing?: number; + /** Metadata key containing a numeric time value for the timeline layout. */ + timeField?: string; + /** Metadata keys containing longitude and latitude for the geographic layout. */ + longitudeField?: string; + latitudeField?: string; + /** Node types used as the two columns in the bipartite layout. */ + bipartiteTypes?: [string, string]; +}; + +/** A predicate applied to a JSON-compatible node metadata field. */ +export type GraphAttributeFilter = { + field: string; + operator: "equals" | "contains" | "exists" | "gt" | "gte" | "lt" | "lte"; + value?: JSONPrimitive; }; export type GraphFilterState = { @@ -444,6 +473,59 @@ export type GraphFilterState = { selectedTypes: string[]; /** Lowest visible relationship weight. */ minimumLinkWeight: number; + /** Highest visible relationship weight. Omit to include all larger weights. */ + maximumLinkWeight?: number; + /** Metadata predicates combined with AND semantics. */ + attributes?: GraphAttributeFilter[]; +}; + +/** A named, serializable search and filter preset. */ +export type GraphSavedQuery = { + id: string; + name: string; + filters: GraphFilterState; +}; + +/** A user-authored note attached to a graph entity or a saved camera view. */ +export type GraphAnnotation = { + id: string; + target: { kind: "node"; nodeId: string } | { kind: "link"; linkId: string } | { kind: "view" }; + body: string; + author?: string; + createdAt: string; + updatedAt?: string; +}; + +/** A named, shareable graph state. */ +export type GraphBookmark = { + id: string; + name: string; + view: OrbitGraphViewState; + createdAt: string; +}; + +/** Aggregate visual cluster returned by community clustering. */ +export type GraphCluster = { + id: string; + label: string; + nodeIds: string[]; + linkIds: string[]; + collapsed: boolean; +}; + +/** Optional rendering hooks for application-owned HTML UI. */ +export type GraphUIRenderers = { + renderTooltip?: (selection: GraphSelection) => HTMLElement | null; + renderDetails?: (selection: GraphSelection) => HTMLElement | null; +}; + +/** Enables semantic and visual performance helpers. */ +export type GraphPerformanceOptions = { + /** Enables automatic label/detail reduction as visible node count grows. @defaultValue true */ + levelOfDetail?: boolean; + /** Calls `onPerformanceSample` roughly once per second. */ + telemetry?: boolean; + onPerformanceSample?: (sample: { fps: number; visibleNodes: number; visibleLinks: number }) => void; }; /** @@ -579,6 +661,9 @@ export type GraphAccessibilityOptions = { * @defaultValue "Interactive relationship graph" */ ariaLabel?: string; + + /** Renders a synchronized, screen-reader-friendly list beside the WebGL canvas. @defaultValue false */ + semanticView?: boolean; }; /** Selects which graph data is included in a JSON export. */ @@ -662,4 +747,4 @@ export type GraphLoadingState = { nodeId: string | null; /** Most recent loading error. Cleared when a new request begins or succeeds. */ error: GraphLoadError | null; -}; \ No newline at end of file +}; diff --git a/packages/core/tests/GraphCollaboration.test.ts b/packages/core/tests/GraphCollaboration.test.ts new file mode 100644 index 0000000..ad570f7 --- /dev/null +++ b/packages/core/tests/GraphCollaboration.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { GraphCollaborationStore } from "../src/GraphCollaboration"; + +const view = { version: 1 as const, exploration: { initialView: { mode: "all" as const }, activeView: { mode: "all" as const }, expansions: [], path: null }, filters: { searchQuery: "", selectedTypes: [], minimumLinkWeight: 0 }, layout: "force" as const, layoutOptions: {} }; + +describe("GraphCollaborationStore", () => { + it("exports annotations and immutable bookmark view snapshots", () => { + const store = new GraphCollaborationStore(); + store.upsertAnnotation({ id: "note", target: { kind: "node", nodeId: "api" }, body: "Review", createdAt: "2026-01-01T00:00:00.000Z" }); + store.createBookmark("review", "Review", view); + view.filters.searchQuery = "mutated"; + expect(store.export().annotations).toHaveLength(1); + expect(store.getBookmarks()[0].view.filters.searchQuery).toBe(""); + }); +}); diff --git a/packages/core/tests/GraphImporters.test.ts b/packages/core/tests/GraphImporters.test.ts new file mode 100644 index 0000000..5132c37 --- /dev/null +++ b/packages/core/tests/GraphImporters.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { importCSVLinks, importCSVNodes, importJSONLD } from "../src/GraphImporters"; + +describe("GraphImporters", () => { + it("preserves extra CSV columns as metadata", () => { + expect(importCSVNodes("id,label,team\na,API,platform")).toEqual([{ id: "a", label: "API", data: { team: "platform" } }]); + expect(importCSVLinks("source,target,weight\na,b,0.7")).toEqual([{ source: "a", target: "b", weight: 0.7, data: {} }]); + }); + + it("converts JSON-LD IRI references into relationships", () => { + const graph = importJSONLD([{ "@id": "a", knows: "b" }, { "@id": "b", name: "B" }]); + expect(graph.links).toEqual([{ id: "a:knows:b", source: "a", target: "b", type: "knows" }]); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index fcc58b2..303229b 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -99,6 +99,18 @@ type OrbitGraphHandle = { getAnalytics(): GraphAnalyticsController; getPresentation(): GraphPresentationController; + + setAdvancedFilters(options: { minimumLinkWeight?: number; maximumLinkWeight?: number; attributes?: GraphAttributeFilter[] }): void; + shareView(): string; + loadSharedView(encodedState: string): void; + addAnnotation(annotation: GraphAnnotation): void; + getAnnotations(): GraphAnnotation[]; + saveBookmark(id: string, name: string): GraphBookmark | undefined; + restoreBookmark(id: string): boolean; + + clusterCommunities(): GraphCluster[]; + collapseCluster(clusterId: string): void; + expandCluster(clusterId: string): void; }; ``` @@ -138,6 +150,19 @@ graph?.getPresentation().setNodeStyles({ Use `getAnalytics()` for degree, PageRank, betweenness, and communities. Use `getPresentation()` to turn results into temporary color, size, and glow styles. +## Advanced exploration + +```tsx + +``` + +The ref also exposes `setAdvancedFilters()`, shared-view serialization, annotations, and bookmarks. Configure tooltip/detail renderers, semantic accessibility, and telemetry through the component's `options` prop exactly as in `@orbitgraph/three`. + ## Related packages - [`@orbitgraph/core`](https://www.npmjs.com/package/@orbitgraph/core): shared types and analysis utilities. diff --git a/packages/react/package.json b/packages/react/package.json index 2c96489..f9d090c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/react", - "version": "1.2.0", + "version": "1.3.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.2.0", - "@orbitgraph/three": "^1.2.0" + "@orbitgraph/core": "^1.3.0", + "@orbitgraph/three": "^1.3.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/src/OrbitGraph.tsx b/packages/react/src/OrbitGraph.tsx index a6bb8fc..e43fab1 100644 --- a/packages/react/src/OrbitGraph.tsx +++ b/packages/react/src/OrbitGraph.tsx @@ -9,6 +9,10 @@ import { import type { GraphData, + GraphAnnotation, + GraphBookmark, + GraphAttributeFilter, + GraphCluster, GraphDiagnostic, GraphExpansionOptions, GraphInitialView, @@ -89,6 +93,19 @@ export type OrbitGraphHandle = { /** Returns the current lazy-loading operation, when configured. */ getLoadingState(): GraphLoadingState; + + /** Applies relationship-weight and metadata predicates. */ + setAdvancedFilters(options: { minimumLinkWeight?: number; maximumLinkWeight?: number; attributes?: GraphAttributeFilter[] }): void; + /** Serializes the current exploration, filters, and layout for a URL. */ + shareView(): string; + loadSharedView(encodedState: string): void; + addAnnotation(annotation: GraphAnnotation): void; + getAnnotations(): GraphAnnotation[]; + saveBookmark(id: string, name: string): GraphBookmark | undefined; + restoreBookmark(id: string): boolean; + clusterCommunities(): GraphCluster[]; + collapseCluster(clusterId: string): void; + expandCluster(clusterId: string): void; }; export type OrbitGraphProps = Omit< @@ -263,6 +280,16 @@ export const OrbitGraph = forwardRef( error: null, }; }, + setAdvancedFilters: (filterOptions) => graphRef.current?.setAdvancedFilters(filterOptions), + shareView: () => graphRef.current?.shareView() ?? "", + loadSharedView: (encodedState) => graphRef.current?.loadSharedView(encodedState), + addAnnotation: (annotation) => graphRef.current?.addAnnotation(annotation), + getAnnotations: () => graphRef.current?.getAnnotations() ?? [], + saveBookmark: (id, name) => graphRef.current?.saveBookmark(id, name), + restoreBookmark: (id) => graphRef.current?.restoreBookmark(id) ?? false, + clusterCommunities: () => graphRef.current?.clusterCommunities() ?? [], + collapseCluster: (id) => graphRef.current?.collapseCluster(id), + expandCluster: (id) => graphRef.current?.expandCluster(id), }), [], ); @@ -332,4 +359,4 @@ export const OrbitGraph = forwardRef( /> ); }, -); \ No newline at end of file +); diff --git a/packages/three/README.md b/packages/three/README.md index c87205c..38ded76 100644 --- a/packages/three/README.md +++ b/packages/three/README.md @@ -2,7 +2,7 @@ Three.js/WebGL renderer for OrbitGraph. -It provides the imperative graph instance, interaction model, progressive exploration, Worker physics, layouts, analytics controllers, visual presentation, exports, intelligent labels, and mini-map navigation. +It provides the imperative graph instance, interaction model, progressive exploration, Worker physics, layouts, analytics controllers, visual presentation, collaboration helpers, exports, intelligent labels, mini-map navigation, and accessibility views. ## Install @@ -52,6 +52,10 @@ graph.showAll(); graph.search("api"); graph.setTypeFilters(["team", "service"]); graph.setMinimumLinkWeight(0.7); +graph.setAdvancedFilters({ + minimumLinkWeight: 0.4, + attributes: [{ field: "environment", operator: "equals", value: "production" }], +}); graph.clearFilters(); graph.focusNode("api"); @@ -60,7 +64,7 @@ graph.unpinNode("api"); graph.destroy(); ``` -For the complete API and option definitions, see the [API reference](../../docs/API.md). +For the complete API and option definitions, see the [API reference](../../docs/api/API.md). ## Remote loading @@ -98,6 +102,27 @@ graph.presentation.clearNodeStyles(); Analytics are read-only. `presentation` applies temporary visual styles without mutating source graph data. +## Clusters, collaboration, and shared views + +```ts +const clusters = graph.clusterCommunities(); +graph.collapseCluster(clusters[0].id); +graph.expandCluster(clusters[0].id); + +const sharedView = graph.shareView(); +graph.loadSharedView(sharedView); + +graph.addAnnotation({ + id: "api-review", + target: { kind: "node", nodeId: "api" }, + body: "Review this dependency.", + createdAt: new Date().toISOString(), +}); +graph.saveBookmark("review", "Dependency review"); +``` + +`clusterCommunities()` uses the visible graph and applies a presentation overlay to distinguish each community. Collapsing a cluster temporarily hides its members; source graph data remains unchanged. Annotations and bookmarks live in `graph.collaboration`; persist `graph.collaboration.export()` in the application when collaboration must survive a page reload. + ## Visual and interaction options ```ts @@ -112,9 +137,18 @@ const graph = createOrbitGraph(container, { miniMap: { enabled: true, position: "bottom-right", interactive: true }, camera: { keyboardNavigation: true, minDistance: 2, maxDistance: 1000 }, physics: { worker: true, tickRate: 60 }, + accessibility: { semanticView: true }, + performance: { + telemetry: true, + onPerformanceSample: ({ fps, visibleNodes }) => console.log(fps, visibleNodes), + }, }); ``` +`ui.renderTooltip` and `ui.renderDetails` may return application-owned `HTMLElement` content for the current selection. The renderer mounts it without imposing a framework. Persistent labels automatically reduce on views larger than 1,000 nodes; set `performance.levelOfDetail` to `false` to opt out. + +The layout names `"dag"` and `"sankey"` use directed layers. `"timeline"` reads `data[timeField]`, `"bipartite"` uses `bipartiteTypes`, and `"geographic"` reads longitude and latitude metadata. + ## Events ```ts diff --git a/packages/three/package.json b/packages/three/package.json index ca0b08c..4ed4086 100644 --- a/packages/three/package.json +++ b/packages/three/package.json @@ -1,6 +1,6 @@ { "name": "@orbitgraph/three", - "version": "1.2.0", + "version": "1.3.0", "description": "Three.js renderer for OrbitGraph.", "type": "module", "main": "./dist/index.cjs", @@ -20,7 +20,7 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean" }, "dependencies": { - "@orbitgraph/core": "^1.2.0", + "@orbitgraph/core": "^1.3.0", "d3-force-3d": "^3.0.6" }, "peerDependencies": { diff --git a/packages/three/src/GraphFilter.ts b/packages/three/src/GraphFilter.ts index bc5d519..219f9e6 100644 --- a/packages/three/src/GraphFilter.ts +++ b/packages/three/src/GraphFilter.ts @@ -10,6 +10,11 @@ export class GraphFilter { private searchQuery = ""; private selectedTypes = new Set(); private minimumLinkWeight = 0; + private maximumLinkWeight: number | undefined; + private attributes: NonNullable = []; + private hiddenNodeIds = new Set(); + + setHiddenNodeIds(nodeIds: Iterable): void { this.hiddenNodeIds = new Set(nodeIds); } search(query: string): void { this.searchQuery = query.trim().toLowerCase(); @@ -39,12 +44,23 @@ export class GraphFilter { return this.minimumLinkWeight; } + setLinkWeightRange(minimum = 0, maximum?: number): void { + this.setMinimumLinkWeight(minimum); + this.maximumLinkWeight = maximum === undefined ? undefined : Math.max(this.minimumLinkWeight, Math.min(1, maximum)); + } + + setAttributeFilters(attributes: NonNullable): void { + this.attributes = attributes.map((filter) => ({ ...filter })); + } + /** Returns a JSON-serializable snapshot of the active filter settings. */ getState(): GraphFilterState { return { searchQuery: this.searchQuery, selectedTypes: this.getSelectedTypes(), minimumLinkWeight: this.minimumLinkWeight, + ...(this.maximumLinkWeight === undefined ? {} : { maximumLinkWeight: this.maximumLinkWeight }), + ...(this.attributes.length === 0 ? {} : { attributes: this.attributes.map((filter) => ({ ...filter })) }), }; } @@ -53,12 +69,17 @@ export class GraphFilter { this.search(state.searchQuery); this.setTypes(state.selectedTypes); this.setMinimumLinkWeight(state.minimumLinkWeight); + this.maximumLinkWeight = state.maximumLinkWeight; + this.setAttributeFilters(state.attributes ?? []); } clear(): void { this.searchQuery = ""; this.selectedTypes.clear(); this.minimumLinkWeight = 0; + this.maximumLinkWeight = undefined; + this.attributes = []; + this.hiddenNodeIds.clear(); } getVisibleData(data: GraphData): VisibleGraphData { @@ -70,6 +91,7 @@ export class GraphFilter { visibleNodeIds.has(link.source) && visibleNodeIds.has(link.target) && (link.weight ?? 1) >= this.minimumLinkWeight + && (this.maximumLinkWeight === undefined || (link.weight ?? 1) <= this.maximumLinkWeight) ); }); @@ -77,6 +99,7 @@ export class GraphFilter { } private matchesNode(node: GraphNode): boolean { + if (this.hiddenNodeIds.has(node.id)) return false; const typeMatches = this.selectedTypes.size === 0 || (node.type !== undefined && this.selectedTypes.has(node.type)); @@ -85,9 +108,8 @@ export class GraphFilter { return false; } - if (!this.searchQuery) { - return true; - } + if (!this.attributes.every((filter) => this.matchesAttribute(node, filter))) return false; + if (!this.searchQuery) return true; const searchableText = [ node.id, @@ -101,4 +123,17 @@ export class GraphFilter { return searchableText.includes(this.searchQuery); } -} \ No newline at end of file + + private matchesAttribute(node: GraphNode, filter: NonNullable[number]): boolean { + const value = node.data?.[filter.field]; + if (filter.operator === "exists") return value !== undefined; + if (value === undefined || filter.value === undefined) return false; + if (filter.operator === "contains") return String(value).toLowerCase().includes(String(filter.value).toLowerCase()); + if (filter.operator === "equals") return value === filter.value; + if (typeof value !== "number" || typeof filter.value !== "number") return false; + if (filter.operator === "gt") return value > filter.value; + if (filter.operator === "gte") return value >= filter.value; + if (filter.operator === "lt") return value < filter.value; + return value <= filter.value; + } +} diff --git a/packages/three/src/GraphLayoutEngine.ts b/packages/three/src/GraphLayoutEngine.ts index 9f306c8..774634a 100644 --- a/packages/three/src/GraphLayoutEngine.ts +++ b/packages/three/src/GraphLayoutEngine.ts @@ -28,12 +28,61 @@ export class GraphLayoutEngine { return this.getGridPositions(nodes, options); case "hierarchical": return this.getHierarchicalPositions(nodes, links, options); + case "dag": + case "sankey": + return this.getHierarchicalPositions(nodes, links, options); + case "timeline": + return this.getTimelinePositions(nodes, options); + case "bipartite": + return this.getBipartitePositions(nodes, options); + case "geographic": + return this.getGeographicPositions(nodes, options); case "force": default: return new Map(); } } + private getTimelinePositions(nodes: PhysicsNode[], options: GraphLayoutOptions): Map { + const spacing = options.spacing ?? 12; + const field = options.timeField ?? "time"; + const values = nodes.map((node, index) => { + const candidate = Number(node.data?.[field]); + return { node, value: Number.isFinite(candidate) ? candidate : index }; + }); + const min = Math.min(...values.map(({ value }) => value)); + const max = Math.max(...values.map(({ value }) => value)); + return new Map(values.map(({ node, value }, index) => [node.id, { + x: max === min ? 0 : ((value - min) / (max - min) - 0.5) * Math.max(spacing * nodes.length, spacing), + y: ((index % 3) - 1) * spacing * 0.35, + z: 0, + }])); + } + + private getBipartitePositions(nodes: PhysicsNode[], options: GraphLayoutOptions): Map { + const spacing = options.spacing ?? 12; + const [leftType] = options.bipartiteTypes ?? [nodes[0]?.type ?? ""]; + const columns = [nodes.filter((node) => node.type === leftType), nodes.filter((node) => node.type !== leftType)]; + const positions = new Map(); + columns.forEach((column, columnIndex) => column.forEach((node, index) => positions.set(node.id, { + x: (columnIndex === 0 ? -1 : 1) * spacing * 3, + y: (index - (column.length - 1) / 2) * spacing, + z: 0, + }))); + return positions; + } + + private getGeographicPositions(nodes: PhysicsNode[], options: GraphLayoutOptions): Map { + const longitude = options.longitudeField ?? "longitude"; + const latitude = options.latitudeField ?? "latitude"; + const spacing = options.spacing ?? 0.5; + return new Map(nodes.map((node) => [node.id, { + x: Number(node.data?.[longitude] ?? 0) * spacing, + y: Number(node.data?.[latitude] ?? 0) * spacing, + z: 0, + }])); + } + private getRadialPositions( nodes: PhysicsNode[], options: GraphLayoutOptions, @@ -188,4 +237,4 @@ export class GraphLayoutEngine { return null; } -} \ No newline at end of file +} diff --git a/packages/three/src/GraphRuntime.ts b/packages/three/src/GraphRuntime.ts index 1035403..488c0f6 100644 --- a/packages/three/src/GraphRuntime.ts +++ b/packages/three/src/GraphRuntime.ts @@ -7,6 +7,10 @@ import { LinkParticleRenderer } from "./LinkParticleRenderer"; export class GraphRuntime { private frameId: number | null = null; private previousFrameTime = performance.now(); + private frames = 0; + private sampleStartedAt = performance.now(); + private visibleNodes = 0; + private visibleLinks = 0; constructor( private readonly renderer: THREE.WebGLRenderer, @@ -14,8 +18,11 @@ export class GraphRuntime { private readonly camera: THREE.PerspectiveCamera, private readonly graphCamera: GraphCamera, private readonly particles: LinkParticleRenderer, + private readonly performanceOptions?: { telemetry?: boolean; onPerformanceSample?: (sample: { fps: number; visibleNodes: number; visibleLinks: number }) => void }, ) {} + setVisibleCounts(nodes: number, links: number): void { this.visibleNodes = nodes; this.visibleLinks = links; } + start(): void { if (this.frameId !== null) { return; @@ -55,5 +62,12 @@ export class GraphRuntime { this.graphCamera.update(deltaSeconds); this.particles.update(now / 1000); this.renderer.render(this.scene, this.camera); + this.frames += 1; + const elapsed = now - this.sampleStartedAt; + if (this.performanceOptions?.telemetry && elapsed >= 1000) { + this.performanceOptions.onPerformanceSample?.({ fps: Math.round((this.frames * 1000) / elapsed), visibleNodes: this.visibleNodes, visibleLinks: this.visibleLinks }); + this.frames = 0; + this.sampleStartedAt = now; + } }; -} \ No newline at end of file +} diff --git a/packages/three/src/GraphUIController.ts b/packages/three/src/GraphUIController.ts new file mode 100644 index 0000000..a200854 --- /dev/null +++ b/packages/three/src/GraphUIController.ts @@ -0,0 +1,40 @@ +import type { GraphNode, GraphSelection, GraphUIRenderers } from "@orbitgraph/core"; + +/** Mounts optional application-rendered tooltip and detail content without prescribing a UI framework. */ +export class GraphUIController { + private tooltip: HTMLDivElement | null = null; + private details: HTMLDivElement | null = null; + private semantic: HTMLUListElement | null = null; + + constructor(private readonly container: HTMLElement, private readonly renderers?: GraphUIRenderers, semanticView = false) { + if (renderers?.renderTooltip) this.tooltip = this.createRoot("orbitgraph-tooltip", "tooltip"); + if (renderers?.renderDetails) this.details = this.createRoot("orbitgraph-details", "region"); + if (semanticView) { this.semantic = document.createElement("ul"); this.semantic.className = "orbitgraph-semantic-view"; this.semantic.setAttribute("aria-label", "Visible graph nodes"); container.appendChild(this.semantic); } + } + + update(selection: GraphSelection): void { + this.render(this.tooltip, this.renderers?.renderTooltip?.(selection) ?? null); + this.render(this.details, this.renderers?.renderDetails?.(selection) ?? null); + } + + dispose(): void { this.tooltip?.remove(); this.details?.remove(); this.semantic?.remove(); } + + setSemanticNodes(nodes: GraphNode[]): void { + if (!this.semantic) return; + this.semantic.replaceChildren(...nodes.map((node) => { const item = document.createElement("li"); item.textContent = `${node.label ?? node.id}${node.type ? ` (${node.type})` : ""}`; return item; })); + } + + private createRoot(className: string, role: string): HTMLDivElement { + const root = document.createElement("div"); + root.className = className; + root.setAttribute("role", role); + this.container.appendChild(root); + return root; + } + + private render(root: HTMLDivElement | null, content: HTMLElement | null): void { + if (!root) return; + root.replaceChildren(...(content ? [content] : [])); + root.hidden = !content; + } +} diff --git a/packages/three/src/OrbitGraph.ts b/packages/three/src/OrbitGraph.ts index 7b4988f..905e04c 100644 --- a/packages/three/src/OrbitGraph.ts +++ b/packages/three/src/OrbitGraph.ts @@ -5,6 +5,10 @@ import type { GraphData, GraphDataSource, GraphExpansionOptions, + GraphAttributeFilter, + GraphAnnotation, + GraphBookmark, + GraphCluster, GraphExplorationHistoryState, GraphInitialView, GraphLayout, @@ -22,6 +26,7 @@ import type { OrbitGraphViewState, VisibleGraphData, } from "@orbitgraph/core"; +import { GraphCollaborationStore } from "@orbitgraph/core"; import { GraphCamera } from "./GraphCamera"; import { GraphDataStore } from "./GraphDataStore"; @@ -41,6 +46,7 @@ import { NodeLabelRenderer } from "./NodeLabelRenderer"; import { PhysicsEngine } from "./PhysicsEngine"; import { GraphAnalyticsController } from "./GraphAnalyticsController"; import { GraphPresentationController } from "./GraphPresentationController"; +import { GraphUIController } from "./GraphUIController"; import type { GraphLinkArrowMap, GraphLinkLineMap, @@ -66,6 +72,8 @@ export class OrbitGraph { private readonly physics: PhysicsEngine; public readonly analytics: GraphAnalyticsController; public readonly presentation: GraphPresentationController; + /** Serializable annotations and named view bookmarks; persistence is owned by the host app. */ + public readonly collaboration = new GraphCollaborationStore(); /** Active objects only; they are replaced after each view refresh. */ private readonly nodes: GraphNodeMap = new Map(); @@ -85,10 +93,12 @@ export class OrbitGraph { private readonly exporter: GraphExporter; private readonly mobileControls: GraphMobileControls; private readonly miniMap: GraphMiniMap; + private readonly ui: GraphUIController; private layout: GraphLayout; private layoutOptions: GraphLayoutOptions; private visibleData: VisibleGraphData = { nodes: [], links: [] }; + private clusters: GraphCluster[] = []; constructor( private readonly container: HTMLElement, @@ -162,6 +172,7 @@ export class OrbitGraph { this.graphCamera, options.miniMap, ); + this.ui = new GraphUIController(this.container, options.ui, options.accessibility?.semanticView); this.views = new GraphViewSynchronizer( this.dataStore, @@ -203,6 +214,7 @@ export class OrbitGraph { this.camera, this.graphCamera, this.particles, + options.performance, ); this.exporter = new GraphExporter({ @@ -449,6 +461,56 @@ export class OrbitGraph { this.refreshVisibleGraph(); } + /** Applies inclusive relationship-weight and metadata predicates together. */ + setAdvancedFilters(options: { minimumLinkWeight?: number; maximumLinkWeight?: number; attributes?: GraphAttributeFilter[] }): void { + this.filter.setLinkWeightRange(options.minimumLinkWeight, options.maximumLinkWeight); + this.filter.setAttributeFilters(options.attributes ?? []); + this.refreshVisibleGraph(); + } + + /** Encodes the current serializable view state for URLs, bookmarks, or collaboration. */ + shareView(): string { + return encodeURIComponent(JSON.stringify(this.exportViewState())); + } + + /** Restores a state generated by `shareView`. */ + loadSharedView(encodedState: string): void { + this.importViewState(JSON.parse(decodeURIComponent(encodedState)) as OrbitGraphViewState); + } + + addAnnotation(annotation: GraphAnnotation): void { this.collaboration.upsertAnnotation(annotation); } + removeAnnotation(id: string): void { this.collaboration.removeAnnotation(id); } + getAnnotations(): GraphAnnotation[] { return this.collaboration.getAnnotations(); } + saveBookmark(id: string, name: string): GraphBookmark { return this.collaboration.createBookmark(id, name, this.exportViewState()); } + getBookmarks(): GraphBookmark[] { return this.collaboration.getBookmarks(); } + restoreBookmark(id: string): boolean { + const bookmark = this.collaboration.getBookmarks().find((item) => item.id === id); + if (!bookmark) return false; + this.importViewState(bookmark.view); + return true; + } + + /** Detects communities, colors them consistently, and exposes collapsible cluster state. */ + clusterCommunities(): GraphCluster[] { + const result = this.analytics.detectCommunities({ scope: "visible" }); + const palette = ["#22d3ee", "#a78bfa", "#f59e0b", "#34d399", "#fb7185", "#60a5fa"]; + this.clusters = result.communities.map((community, index) => ({ + id: community.id, + label: `Community ${index + 1}`, + nodeIds: community.nodeIds, + linkIds: this.getClusterLinkIds(community.nodeIds), + collapsed: false, + })); + this.presentation.setNodeStyles(Object.fromEntries(this.clusters.flatMap((cluster, index) => cluster.nodeIds.map((nodeId) => [nodeId, { color: palette[index % palette.length], glow: 0.55 }])))); + return this.getClusters(); + } + + getClusters(): GraphCluster[] { return this.clusters.map((cluster) => ({ ...cluster, nodeIds: [...cluster.nodeIds], linkIds: [...cluster.linkIds] })); } + + /** Hides a community's members until `expandCluster` restores them. */ + collapseCluster(clusterId: string): void { this.setClusterCollapsed(clusterId, true); } + expandCluster(clusterId: string): void { this.setClusterCollapsed(clusterId, false); } + clearFilters(): void { this.filter.clear(); this.refreshVisibleGraph(); @@ -547,6 +609,7 @@ export class OrbitGraph { this.keyboardNavigation.dispose(); this.interaction.dispose(); this.miniMap.dispose(); + this.ui.dispose(); this.graphCamera.dispose(); this.views.clear(); this.physics.dispose(); @@ -561,6 +624,27 @@ export class OrbitGraph { private refreshVisibleGraph(): void { this.visibleData = this.views.refresh(); + this.runtime.setVisibleCounts(this.visibleData.nodes.length, this.visibleData.links.length); + this.ui.setSemanticNodes(this.visibleData.nodes); + 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) }); + } + } + + private setClusterCollapsed(clusterId: string, collapsed: boolean): void { + const cluster = this.clusters.find((item) => item.id === clusterId); + if (!cluster) return; + cluster.collapsed = collapsed; + this.filter.setHiddenNodeIds(this.clusters.filter((item) => item.collapsed).flatMap((item) => item.nodeIds)); + this.refreshVisibleGraph(); + } + + /** Uses a Set so grouping remains linear even for communities with thousands of nodes. */ + private getClusterLinkIds(nodeIds: string[]): string[] { + const members = new Set(nodeIds); + return this.visibleData.links + .filter((link) => members.has(link.source) && members.has(link.target)) + .map((link) => link.id ?? `${link.source}__${link.target}`); } private emitSelection(selection: GraphSelection): void { @@ -568,6 +652,7 @@ export class OrbitGraph { selection?.kind === "node" ? selection.node : null, ); this.options.onSelectionChange?.(selection); + this.ui.update(selection); } } @@ -577,4 +662,4 @@ export function createOrbitGraph( options?: OrbitGraphOptions, ): OrbitGraph { return new OrbitGraph(container, options); -} \ No newline at end of file +} diff --git a/packages/three/tests/GraphFilter.test.ts b/packages/three/tests/GraphFilter.test.ts index 6b167a7..9950512 100644 --- a/packages/three/tests/GraphFilter.test.ts +++ b/packages/three/tests/GraphFilter.test.ts @@ -70,4 +70,25 @@ describe("GraphFilter", () => { expect(filter.getVisibleData(data)).toEqual(data); }); -}); \ No newline at end of file + + it("combines an inclusive weight range and metadata predicates", () => { + const filter = new GraphFilter(); + filter.setLinkWeightRange(0.3, 0.5); + filter.setAttributeFilters([{ field: "environment", operator: "equals", value: "production" }]); + const result = filter.getVisibleData({ + nodes: [ + { id: "api", data: { environment: "production", replicas: 3 } }, + { id: "worker", data: { environment: "staging", replicas: 8 } }, + ], + links: [{ id: "api-worker", source: "api", target: "worker", weight: 0.4 }], + }); + expect(result).toEqual({ nodes: [{ id: "api", data: { environment: "production", replicas: 3 } }], links: [] }); + }); + + it("can hide a collapsed cluster without changing source data", () => { + const filter = new GraphFilter(); + filter.setHiddenNodeIds(["api"]); + expect(filter.getVisibleData(data).nodes.map((node) => node.id)).toEqual(["manuel", "postgres"]); + expect(data.nodes).toHaveLength(3); + }); +}); diff --git a/packages/three/tests/GraphLayoutEngine.test.ts b/packages/three/tests/GraphLayoutEngine.test.ts index 46cfbbf..3bfd352 100644 --- a/packages/three/tests/GraphLayoutEngine.test.ts +++ b/packages/three/tests/GraphLayoutEngine.test.ts @@ -57,4 +57,14 @@ describe("GraphLayoutEngine", () => { it("returns no target positions for the force layout", () => { expect(engine.getPositions(nodes, links, "force")).toEqual(new Map()); }); -}); \ No newline at end of file + + it("supports timeline, bipartite, and geographic metadata layouts", () => { + const metadataNodes: PhysicsNode[] = [ + { id: "early", type: "left", x: 0, y: 0, z: 0, data: { time: 10, longitude: -100, latitude: 20 } }, + { id: "late", type: "right", x: 0, y: 0, z: 0, data: { time: 20, longitude: -90, latitude: 30 } }, + ]; + expect(engine.getPositions(metadataNodes, [], "timeline").get("early")!.x).toBeLessThan(engine.getPositions(metadataNodes, [], "timeline").get("late")!.x); + expect(engine.getPositions(metadataNodes, [], "bipartite", { bipartiteTypes: ["left", "right"] }).get("early")!.x).toBeLessThan(0); + expect(engine.getPositions(metadataNodes, [], "geographic").get("late")).toMatchObject({ x: -45, y: 15, z: 0 }); + }); +}); From 94bbdf943ee3229c457ec7e6646d7af6401cb5dc Mon Sep 17 00:00:00 2001 From: Manuel Garcia Date: Tue, 11 Aug 2026 19:27:28 -0600 Subject: [PATCH 2/2] feat: add v1.4 graph platform foundations --- CHANGELOG.md | 14 +- README.MD | 13 + docs/api/API.md | 11 + examples/benchmark/src/main.ts | 10 +- package-lock.json | 449 ++++++++++++++++++- packages/core/package.json | 2 +- packages/core/src/GraphClusterAggregator.ts | 10 + packages/core/src/GraphDiff.ts | 17 + packages/core/src/GraphRouting.ts | 19 + packages/core/src/index.ts | 3 + packages/core/src/types.ts | 41 ++ packages/core/tests/GraphDiffRouting.test.ts | 9 + packages/react/package.json | 6 +- packages/react/src/OrbitGraph.tsx | 12 + packages/three/package.json | 8 +- packages/three/src/CanvasGraphRenderer.ts | 14 + packages/three/src/GraphExporter.ts | 15 +- packages/three/src/GraphHistory.ts | 8 + packages/three/src/GraphPluginRegistry.ts | 8 + packages/three/src/MapLibreGraphLayer.ts | 10 + packages/three/src/OrbitGraph.ts | 53 ++- packages/three/src/index.ts | 5 +- 22 files changed, 716 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/GraphClusterAggregator.ts create mode 100644 packages/core/src/GraphDiff.ts create mode 100644 packages/core/src/GraphRouting.ts create mode 100644 packages/core/tests/GraphDiffRouting.test.ts create mode 100644 packages/three/src/CanvasGraphRenderer.ts create mode 100644 packages/three/src/GraphHistory.ts create mode 100644 packages/three/src/GraphPluginRegistry.ts create mode 100644 packages/three/src/MapLibreGraphLayer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d29fbe..9526ef2 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 @@ -17,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Optional FPS/visible-count telemetry, adaptive label level of detail, and an accessible synchronized semantic node list. - React ref bindings for advanced filters, shared views, annotations, and bookmarks. + ## [1.2.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";