From 38455e79dfa95621a2ff41d693aacd64c6103e5d Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 20 Sep 2024 17:31:09 +0200 Subject: [PATCH] [utils] Drafts @sigma/utils Details: - Drafts new package @sigma/utils - Adds `fitNodesToViewport` and `getCameraStateToFitNodesToViewport` - Adds a new story to showcase `fitNodesToViewport` --- package-lock.json | 12 ++ package.json | 3 +- .../stories/utils/fit-nodes-to-viewport.ts | 64 +++++++++++ packages/storybook/stories/utils/index.html | 14 +++ packages/storybook/stories/utils/stories.ts | 25 +++++ packages/utils/.gitignore | 2 + packages/utils/.npmignore | 4 + packages/utils/README.md | 7 ++ packages/utils/package.json | 34 ++++++ packages/utils/src/fitNodesToViewport.ts | 104 ++++++++++++++++++ packages/utils/src/index.ts | 1 + packages/utils/tsconfig.json | 23 ++++ tsconfig.json | 51 +++++++-- 13 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 packages/storybook/stories/utils/fit-nodes-to-viewport.ts create mode 100644 packages/storybook/stories/utils/index.html create mode 100644 packages/storybook/stories/utils/stories.ts create mode 100644 packages/utils/.gitignore create mode 100644 packages/utils/.npmignore create mode 100644 packages/utils/README.md create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/fitNodesToViewport.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 85b164054..26336ac00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5074,6 +5074,10 @@ "resolved": "packages/test", "link": true }, + "node_modules/@sigma/utils": { + "resolved": "packages/utils", + "link": true + }, "node_modules/@sigma/website": { "resolved": "packages/website", "link": true @@ -31163,6 +31167,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/utils": { + "name": "@sigma/utils", + "version": "0.0.1-alpha.0", + "license": "MIT", + "peerDependencies": { + "sigma": ">=3.0.0-beta.29" + } + }, "packages/website": { "name": "@sigma/website", "dependencies": { diff --git a/package.json b/package.json index 0ad1d676c..827c25a4f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "packages/edge-curve", "packages/layer-leaflet", "packages/layer-maplibre", - "packages/layer-webgl" + "packages/layer-webgl", + "packages/utils" ], "exports": { "importConditionDefaultExport": "default" diff --git a/packages/storybook/stories/utils/fit-nodes-to-viewport.ts b/packages/storybook/stories/utils/fit-nodes-to-viewport.ts new file mode 100644 index 000000000..4eac58294 --- /dev/null +++ b/packages/storybook/stories/utils/fit-nodes-to-viewport.ts @@ -0,0 +1,64 @@ +import { fitNodesToViewport } from "@sigma/utils"; +import Graph from "graphology"; +import louvain from "graphology-communities-louvain"; +import iwanthue from "iwanthue"; +import Sigma from "sigma"; + +import data from "../_data/data.json"; +import { onStoryDown } from "../utils"; + +export default () => { + const graph = new Graph(); + graph.import(data); + + // Detect communities + louvain.assign(graph, { nodeCommunityAttribute: "community" }); + const communities = new Set(); + graph.forEachNode((_, attrs) => communities.add(attrs.community)); + const communitiesArray = Array.from(communities); + + // Determine colors, and color each node accordingly + const palette: Record = iwanthue(communities.size).reduce( + (iter, color, i) => ({ + ...iter, + [communitiesArray[i]]: color, + }), + {}, + ); + graph.forEachNode((node, attr) => graph.setNodeAttribute(node, "color", palette[attr.community])); + + // Retrieve some useful DOM elements + const container = document.getElementById("sigma-container") as HTMLElement; + + // Instantiate sigma + const renderer = new Sigma(graph, container); + + // Add buttons + const buttonsContainer = document.createElement("div"); + buttonsContainer.style.position = "absolute"; + buttonsContainer.style.right = "10px"; + buttonsContainer.style.bottom = "10px"; + document.body.append(buttonsContainer); + + communitiesArray.forEach((community) => { + const id = `cb-${community}`; + const buttonContainer = document.createElement("div"); + buttonContainer.innerHTML += ` + + `; + buttonsContainer.append(buttonContainer); + const button = buttonsContainer.querySelector(`#${id}`) as HTMLButtonElement; + + button.addEventListener("click", () => { + fitNodesToViewport( + renderer, + graph.filterNodes((_, attr) => attr.community === community), + { animate: true }, + ); + }); + }); + + onStoryDown(() => { + renderer?.kill(); + }); +}; diff --git a/packages/storybook/stories/utils/index.html b/packages/storybook/stories/utils/index.html new file mode 100644 index 000000000..22b4a10cd --- /dev/null +++ b/packages/storybook/stories/utils/index.html @@ -0,0 +1,14 @@ + +
diff --git a/packages/storybook/stories/utils/stories.ts b/packages/storybook/stories/utils/stories.ts new file mode 100644 index 000000000..e3b2ccaed --- /dev/null +++ b/packages/storybook/stories/utils/stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; + +import FitNodesToViewportPlay from "./fit-nodes-to-viewport"; +import FitNodesToViewportSource from "./fit-nodes-to-viewport?raw"; +import template from "./index.html?raw"; + +const meta: Meta = { + id: "utils", + title: "utils", +}; +export default meta; + +type Story = StoryObj; + +export const FitNodesToViewport: Story = { + name: "Fit nodes to viewport", + render: () => template, + play: FitNodesToViewportPlay, + args: {}, + parameters: { + storySource: { + source: FitNodesToViewportSource, + }, + }, +}; diff --git a/packages/utils/.gitignore b/packages/utils/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/utils/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/utils/.npmignore b/packages/utils/.npmignore new file mode 100644 index 000000000..0e9d6d53f --- /dev/null +++ b/packages/utils/.npmignore @@ -0,0 +1,4 @@ +.gitignore +node_modules +src +tsconfig.json diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 000000000..2aa372981 --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,7 @@ +# Sigma.js - Utils + +This package contains various utils functions to ease the use of [sigma.js](https://www.sigmajs.org/). + +### `fitNodesToViewport` and `getCameraStateToFitNodesToViewport` + +These functions allow moving a sigma instance's camera so that it fits to a given group of nodes. diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..16eb40548 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,34 @@ +{ + "name": "@sigma/utils", + "version": "3.0.0-beta.0", + "description": "A set of utils functions to ease the use of sigma.js", + "main": "dist/sigma-utils.cjs.js", + "module": "dist/sigma-utils.esm.js", + "files": [ + "/dist" + ], + "sideEffects": false, + "homepage": "https://www.sigmajs.org", + "bugs": "http://github.com/jacomyal/sigma.js/issues", + "repository": { + "type": "git", + "url": "http://github.com/jacomyal/sigma.js.git" + }, + "preconstruct": { + "entrypoints": [ + "index.ts" + ] + }, + "peerDependencies": { + "sigma": ">=3.0.0-beta.29" + }, + "license": "MIT", + "exports": { + ".": { + "module": "./dist/sigma-utils.esm.js", + "import": "./dist/sigma-utils.cjs.mjs", + "default": "./dist/sigma-utils.cjs.js" + }, + "./package.json": "./package.json" + } +} diff --git a/packages/utils/src/fitNodesToViewport.ts b/packages/utils/src/fitNodesToViewport.ts new file mode 100644 index 000000000..80072cf8b --- /dev/null +++ b/packages/utils/src/fitNodesToViewport.ts @@ -0,0 +1,104 @@ +import Sigma from "sigma"; +import type { CameraState } from "sigma/types"; +import { getCorrectionRatio } from "sigma/utils"; + +export type FitNodesToScreenOptions = { + animate: boolean; +}; +export const DEFAULT_FIT_NODES_TO_SCREEN_OPTIONS: FitNodesToScreenOptions = { + animate: true, +}; + +/** + * This function takes a Sigma instance and a list of nodes as input, and returns a CameraState so that the camera fits + * the best to the given groups of nodes (i.e. the camera is as zoomed as possible while keeping all nodes on screen). + * + * @param sigma A Sigma instance + * @param nodes A list of nodes IDs + * @param opts An optional and partial FitNodesToScreenOptions object + */ +export function getCameraStateToFitNodesToViewport( + sigma: Sigma, + nodes: string[], + _opts: Partial> = {}, +): CameraState { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + let groupMinX = Infinity; + let groupMaxX = -Infinity; + let groupMinY = Infinity; + let groupMaxY = -Infinity; + let groupFramedMinX = Infinity; + let groupFramedMaxX = -Infinity; + let groupFramedMinY = Infinity; + let groupFramedMaxY = -Infinity; + + const group = new Set(nodes); + const graph = sigma.getGraph(); + graph.forEachNode((node, { x, y }) => { + const data = sigma.getNodeDisplayData(node); + if (!data) throw new Error(`getCameraStateToFitNodesToViewport: Node ${node} not found in sigma's graph.`); + const { x: framedX, y: framedY } = data; + + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + if (group.has(node)) { + groupMinX = Math.min(groupMinX, x); + groupMaxX = Math.max(groupMaxX, x); + groupMinY = Math.min(groupMinY, y); + groupMaxY = Math.max(groupMaxY, y); + groupFramedMinX = Math.min(groupFramedMinX, framedX); + groupFramedMaxX = Math.max(groupFramedMaxX, framedX); + groupFramedMinY = Math.min(groupFramedMinY, framedY); + groupFramedMaxY = Math.max(groupFramedMaxY, framedY); + } + }); + + const groupCenterX = (groupFramedMinX + groupFramedMaxX) / 2; + const groupCenterY = (groupFramedMinY + groupFramedMaxY) / 2; + const groupWidth = groupMaxX - groupMinX || 1; + const groupHeight = groupMaxY - groupMinY || 1; + const graphWidth = maxX - minX || 1; + const graphHeight = maxY - minY || 1; + + const { width, height } = sigma.getDimensions(); + const correction = getCorrectionRatio({ width, height }, { width: graphWidth, height: graphHeight }); + const ratio = + ((groupHeight / groupWidth < height / width ? groupWidth : groupHeight) / Math.max(graphWidth, graphHeight)) * + correction; + + const camera = sigma.getCamera(); + return { + ...camera.getState(), + x: groupCenterX, + y: groupCenterY, + ratio, + }; +} + +/** + * This function takes a Sigma instance and a list of nodes as input, and updates the camera so that the camera fits the + * best to the given groups of nodes (i.e. the camera is as zoomed as possible while keeping all nodes on screen). + * + * @param sigma A Sigma instance + * @param nodes A list of nodes IDs + * @param opts An optional and partial FitNodesToScreenOptions object + */ +export function fitNodesToViewport(sigma: Sigma, nodes: string[], opts: Partial = {}): void { + const { animate } = { + ...DEFAULT_FIT_NODES_TO_SCREEN_OPTIONS, + ...opts, + }; + + const camera = sigma.getCamera(); + const newCameraState = getCameraStateToFitNodesToViewport(sigma, nodes, opts); + if (animate) { + camera.animate(newCameraState); + } else { + camera.setState(newCameraState); + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..9506b92f6 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1 @@ +export * from "./fitNodesToViewport"; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..01de8170f --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true + }, + "include": ["src"], + "exclude": ["src/**/__docs__", "src/**/__test__"] +} diff --git a/tsconfig.json b/tsconfig.json index 67abcf909..a9a54dec4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,18 +2,45 @@ "extends": "./tsconfig.base.json", "files": [], "references": [ - { "path": "./packages/demo" }, - { "path": "./packages/test" }, - { "path": "./packages/storybook" }, - { "path": "./packages/sigma" }, - { "path": "./packages/layer-leaflet" }, - { "path": "./packages/layer-maplibre" }, - { "path": "./packages/layer-webgl" }, - { "path": "./packages/node-border" }, - { "path": "./packages/node-image" }, - { "path": "./packages/node-piechart" }, - { "path": "./packages/node-square" }, - { "path": "./packages/edge-curve" } + { + "path": "./packages/demo" + }, + { + "path": "./packages/test" + }, + { + "path": "./packages/storybook" + }, + { + "path": "./packages/sigma" + }, + { + "path": "./packages/layer-leaflet" + }, + { + "path": "./packages/layer-maplibre" + }, + { + "path": "./packages/layer-webgl" + }, + { + "path": "./packages/node-border" + }, + { + "path": "./packages/node-image" + }, + { + "path": "./packages/node-piechart" + }, + { + "path": "./packages/node-square" + }, + { + "path": "./packages/edge-curve" + }, + { + "path": "./packages/utils" + } ], "watchOptions": { "excludeDirectories": ["**/node_modules"]