diff --git a/package-lock.json b/package-lock.json index 41e78fc4b..ef915a396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4718,6 +4718,10 @@ "resolved": "packages/node-piechart", "link": true }, + "node_modules/@sigma/node-square": { + "resolved": "packages/node-square", + "link": true + }, "node_modules/@sigma/storybook": { "resolved": "packages/storybook", "link": true @@ -29901,6 +29905,14 @@ "sigma": ">=3.0.0-beta.17" } }, + "packages/node-square": { + "name": "@sigma/node-square", + "version": "3.0.0-beta.0", + "license": "MIT", + "peerDependencies": { + "sigma": ">=3.0.0-beta.17" + } + }, "packages/sigma": { "version": "3.0.0-beta.27", "license": "MIT", diff --git a/package.json b/package.json index 9dbe87274..67aec7514 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,14 @@ "preconstruct": { "packages": [ "packages/sigma", - "packages/layer-webgl", "packages/node-border", "packages/node-image", "packages/node-piechart", + "packages/node-square", "packages/edge-curve", "packages/layer-leaflet", - "packages/layer-maplibre" + "packages/layer-maplibre", + "packages/layer-webgl" ], "exports": { "importConditionDefaultExport": "default" diff --git a/packages/node-square/.gitignore b/packages/node-square/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/node-square/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/node-square/.npmignore b/packages/node-square/.npmignore new file mode 100644 index 000000000..0e9d6d53f --- /dev/null +++ b/packages/node-square/.npmignore @@ -0,0 +1,4 @@ +.gitignore +node_modules +src +tsconfig.json diff --git a/packages/node-square/README.md b/packages/node-square/README.md new file mode 100644 index 000000000..8917c5820 --- /dev/null +++ b/packages/node-square/README.md @@ -0,0 +1,29 @@ +# Sigma.js - Node square renderer + +This package contains a node square renderer for [sigma.js](https://sigmajs.org), as well as a proper + +## How to use + +Within your application that uses sigma.js, you can use [`@sigma/node-square`](https://www.npmjs.com/package/@sigma/node-square) as following: + +```typescript +import { NodeSquareProgram } from "@sigma/node-square"; + +const graph = new Graph(); +graph.addNode("some-node", { + x: 0, + y: 0, + size: 10, + type: "square", + label: "Some node", + color: "blue", +}); + +const sigma = new Sigma(graph, container, { + nodeProgramClasses: { + square: NodeSquareProgram, + }, +}); +``` + +Please check the related [Storybook](https://github.com/jacomyal/sigma.js/tree/main/packages/storybook/stories/node-square) for more advanced examples. diff --git a/packages/node-square/package.json b/packages/node-square/package.json new file mode 100644 index 000000000..2f2808ed4 --- /dev/null +++ b/packages/node-square/package.json @@ -0,0 +1,46 @@ +{ + "name": "@sigma/node-square", + "version": "3.0.0-beta.0", + "description": "A node program that renders nodes as squares for sigma.js", + "main": "dist/sigma-node-square.cjs.js", + "module": "dist/sigma-node-square.esm.js", + "types": "dist/sigma-node-square.cjs.d.ts", + "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" + }, + "keywords": [ + "graph", + "graphology", + "sigma" + ], + "contributors": [ + { + "name": "Alexis Jacomy", + "url": "http://github.com/jacomyal" + } + ], + "license": "MIT", + "preconstruct": { + "entrypoints": [ + "index.ts" + ] + }, + "peerDependencies": { + "sigma": ">=3.0.0-beta.17" + }, + "exports": { + ".": { + "module": "./dist/sigma-node-square.esm.js", + "import": "./dist/sigma-node-square.cjs.mjs", + "default": "./dist/sigma-node-square.cjs.js" + }, + "./package.json": "./package.json" + } +} diff --git a/packages/node-square/src/index.ts b/packages/node-square/src/index.ts new file mode 100644 index 000000000..f1449f94f --- /dev/null +++ b/packages/node-square/src/index.ts @@ -0,0 +1,2 @@ +export * from "./utils"; +export { NodeSquareProgram } from "./program"; diff --git a/packages/node-square/src/program.ts b/packages/node-square/src/program.ts new file mode 100644 index 000000000..3a217846c --- /dev/null +++ b/packages/node-square/src/program.ts @@ -0,0 +1,61 @@ +import { Attributes } from "graphology-types"; +import { NodeProgram, ProgramInfo } from "sigma/rendering"; +import { NodeDisplayData, RenderParams } from "sigma/types"; +import { floatColor } from "sigma/utils"; + +import FRAGMENT_SHADER_SOURCE from "./shader-frag"; +import VERTEX_SHADER_SOURCE from "./shader-vert"; +import { drawSquareNodeHover, drawSquareNodeLabel } from "./utils"; + +const { UNSIGNED_BYTE, FLOAT } = WebGLRenderingContext; + +const UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_cameraAngle", "u_matrix"] as const; + +const PI = Math.PI; + +export class NodeSquareProgram< + N extends Attributes = Attributes, + E extends Attributes = Attributes, + G extends Attributes = Attributes, +> extends NodeProgram<(typeof UNIFORMS)[number], N, E, G> { + drawHover = drawSquareNodeHover; + drawLabel = drawSquareNodeLabel; + + getDefinition() { + return { + VERTICES: 6, + VERTEX_SHADER_SOURCE: VERTEX_SHADER_SOURCE, + FRAGMENT_SHADER_SOURCE: FRAGMENT_SHADER_SOURCE, + METHOD: WebGLRenderingContext.TRIANGLES, + UNIFORMS, + ATTRIBUTES: [ + { name: "a_position", size: 2, type: FLOAT }, + { name: "a_size", size: 1, type: FLOAT }, + { name: "a_color", size: 4, type: UNSIGNED_BYTE, normalized: true }, + { name: "a_id", size: 4, type: UNSIGNED_BYTE, normalized: true }, + ], + CONSTANT_ATTRIBUTES: [{ name: "a_angle", size: 1, type: FLOAT }], + CONSTANT_DATA: [[PI / 4], [(3 * PI) / 4], [-PI / 4], [(3 * PI) / 4], [-PI / 4], [(-3 * PI) / 4]], + }; + } + + processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData) { + const array = this.array; + const color = floatColor(data.color); + + array[startIndex++] = data.x; + array[startIndex++] = data.y; + array[startIndex++] = data.size; + array[startIndex++] = color; + array[startIndex++] = nodeIndex; + } + + setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo): void { + const { u_sizeRatio, u_correctionRatio, u_cameraAngle, u_matrix } = uniformLocations; + + gl.uniform1f(u_sizeRatio, params.sizeRatio); + gl.uniform1f(u_cameraAngle, params.cameraAngle); + gl.uniform1f(u_correctionRatio, params.correctionRatio); + gl.uniformMatrix3fv(u_matrix, false, params.matrix); + } +} diff --git a/packages/node-square/src/shader-frag.ts b/packages/node-square/src/shader-frag.ts new file mode 100644 index 000000000..a0436a294 --- /dev/null +++ b/packages/node-square/src/shader-frag.ts @@ -0,0 +1,12 @@ +// language=GLSL +const SHADER_SOURCE = /*glsl*/ ` +precision mediump float; + +varying vec4 v_color; + +void main(void) { + gl_FragColor = v_color; +} +`; + +export default SHADER_SOURCE; diff --git a/packages/node-square/src/shader-vert.ts b/packages/node-square/src/shader-vert.ts new file mode 100644 index 000000000..2751f2659 --- /dev/null +++ b/packages/node-square/src/shader-vert.ts @@ -0,0 +1,40 @@ +// language=GLSL +const SHADER_SOURCE = /*glsl*/ ` +attribute vec4 a_id; +attribute vec4 a_color; +attribute vec2 a_position; +attribute float a_size; +attribute float a_angle; + +uniform mat3 u_matrix; +uniform float u_sizeRatio; +uniform float u_cameraAngle; +uniform float u_correctionRatio; + +varying vec4 v_color; + +const float bias = 255.0 / 254.0; +const float sqrt_8 = sqrt(8.0); + +void main() { + float size = a_size * u_correctionRatio / u_sizeRatio * sqrt_8; + float angle = a_angle + u_cameraAngle; + vec2 diffVector = size * vec2(cos(angle), sin(angle)); + vec2 position = a_position + diffVector; + gl_Position = vec4( + (u_matrix * vec3(position, 1)).xy, + 0, + 1 + ); + + #ifdef PICKING_MODE + v_color = a_id; + #else + v_color = a_color; + #endif + + v_color.a *= bias; +} +`; + +export default SHADER_SOURCE; diff --git a/packages/node-square/src/utils.ts b/packages/node-square/src/utils.ts new file mode 100644 index 000000000..c608bfa9e --- /dev/null +++ b/packages/node-square/src/utils.ts @@ -0,0 +1,70 @@ +import { Attributes } from "graphology-types"; +import { drawDiscNodeLabel } from "sigma/rendering"; +import { Settings } from "sigma/settings"; +import { NodeDisplayData, PartialButFor } from "sigma/types"; + +export function drawSquareNodeLabel< + N extends Attributes = Attributes, + E extends Attributes = Attributes, + G extends Attributes = Attributes, +>( + context: CanvasRenderingContext2D, + data: PartialButFor, + settings: Settings, +): void { + return drawDiscNodeLabel(context, data, settings); +} + +export function drawSquareNodeHover< + N extends Attributes = Attributes, + E extends Attributes = Attributes, + G extends Attributes = Attributes, +>( + context: CanvasRenderingContext2D, + data: PartialButFor, + settings: Settings, +): void { + const size = settings.labelSize, + font = settings.labelFont, + weight = settings.labelWeight; + + context.font = `${weight} ${size}px ${font}`; + + // Then we draw the label background + context.fillStyle = "#FFF"; + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + context.shadowBlur = 8; + context.shadowColor = "#000"; + + const PADDING = 2; + + if (typeof data.label === "string") { + const textWidth = context.measureText(data.label).width, + boxWidth = Math.round(textWidth + 5), + boxHeight = Math.round(size + 2 * PADDING), + radius = Math.max(data.size, size / 2) + PADDING; + + context.beginPath(); + context.moveTo(data.x + radius, data.y + boxHeight / 2); + context.lineTo(data.x + radius + boxWidth, data.y + boxHeight / 2); + context.lineTo(data.x + radius + boxWidth, data.y - boxHeight / 2); + context.lineTo(data.x + radius, data.y - boxHeight / 2); + context.lineTo(data.x + radius, data.y - radius); + context.lineTo(data.x - radius, data.y - radius); + context.lineTo(data.x - radius, data.y + radius); + context.lineTo(data.x + radius, data.y + radius); + context.moveTo(data.x + radius, data.y + boxHeight / 2); + context.closePath(); + context.fill(); + } else { + const radius = data.size + PADDING; + context.fillRect(data.x - radius, data.y - radius, radius * 2, radius * 2); + } + + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + context.shadowBlur = 0; + + drawSquareNodeLabel(context, data, settings); +} diff --git a/packages/node-square/tsconfig.json b/packages/node-square/tsconfig.json new file mode 100644 index 000000000..aedd11da7 --- /dev/null +++ b/packages/node-square/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", // Specifies the JavaScript version to target when transpiling code. + "useDefineForClassFields": true, // Enables the use of 'define' for class fields. + "lib": ["ES2020", "DOM", "DOM.Iterable"], // Specifies the libraries available for the code. + "module": "ESNext", // Defines the module system to use for code generation. + "skipLibCheck": true, // Skips type checking of declaration files. + + /* Bundler mode */ + "moduleResolution": "node", // Specifies how modules are resolved when bundling. + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, // Allows importing TypeScript files with extensions. + "resolveJsonModule": true, // Enables importing JSON modules. + "isolatedModules": true, // Ensures each file is treated as a separate module. + "noEmit": true, // Prevents TypeScript from emitting output files. + + /* Linting */ + "strict": true, // Enables strict type checking. + "noUnusedLocals": true, // Flags unused local variables. + "noUnusedParameters": true, // Flags unused function parameters. + "noFallthroughCasesInSwitch": true, // Requires handling all cases in a switch statement. + "declaration": true // Generates declaration files for TypeScript. + }, + "include": ["src"], // Specifies the directory to include when searching for TypeScript files. + "exclude": ["src/**/__docs__", "src/**/__test__"] +} diff --git a/packages/storybook/stories/node-square/index.html b/packages/storybook/stories/node-square/index.html new file mode 100644 index 000000000..fca1746ba --- /dev/null +++ b/packages/storybook/stories/node-square/index.html @@ -0,0 +1,13 @@ + +
diff --git a/packages/storybook/stories/node-square/mixed-programs.ts b/packages/storybook/stories/node-square/mixed-programs.ts new file mode 100644 index 000000000..e480031b3 --- /dev/null +++ b/packages/storybook/stories/node-square/mixed-programs.ts @@ -0,0 +1,73 @@ +import { NodeSquareProgram } from "@sigma/node-square"; +import Graph from "graphology"; +import Sigma from "sigma"; +import { DEFAULT_NODE_PROGRAM_CLASSES } from "sigma/settings"; + +import { onStoryDown } from "../utils"; + +export default () => { + const container = document.getElementById("sigma-container") as HTMLElement; + + const graph = new Graph(); + + graph.addNode("a", { + x: 0, + y: 0, + size: 20, + label: "A", + }); + graph.addNode("b", { + x: 1, + y: -1, + size: 40, + label: "B", + type: "square", + }); + graph.addNode("c", { + x: 3, + y: -2, + size: 20, + label: "C", + type: "square", + }); + graph.addNode("d", { + x: 1, + y: -3, + size: 20, + label: "D", + }); + graph.addNode("e", { + x: 3, + y: -4, + size: 40, + label: "E", + type: "square", + }); + graph.addNode("f", { + x: 4, + y: -5, + size: 20, + label: "F", + }); + + graph.addEdge("a", "b", { size: 10 }); + graph.addEdge("b", "c", { size: 10 }); + graph.addEdge("b", "d", { size: 10 }); + graph.addEdge("c", "b", { size: 10 }); + graph.addEdge("c", "e", { size: 10 }); + graph.addEdge("d", "c", { size: 10 }); + graph.addEdge("d", "e", { size: 10 }); + graph.addEdge("e", "d", { size: 10 }); + graph.addEdge("f", "e", { size: 10 }); + + const renderer = new Sigma(graph, container, { + nodeProgramClasses: { + ...DEFAULT_NODE_PROGRAM_CLASSES, + square: NodeSquareProgram, + }, + }); + + onStoryDown(() => { + renderer.kill(); + }); +}; diff --git a/packages/storybook/stories/node-square/stories.ts b/packages/storybook/stories/node-square/stories.ts new file mode 100644 index 000000000..7404bf450 --- /dev/null +++ b/packages/storybook/stories/node-square/stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; + +import template from "./index.html?raw"; +import mixedProgramsPlay from "./mixed-programs"; +import mixedProgramsSource from "./mixed-programs?raw"; + +const meta: Meta = { + id: "node-square", + title: "node-square", +}; +export default meta; + +type Story = StoryObj; + +export const mixedPrograms: Story = { + name: "Mixed programs", + render: () => template, + play: mixedProgramsPlay, + args: {}, + parameters: { + storySource: { + source: mixedProgramsSource, + }, + }, +}; diff --git a/packages/website/docs/advanced/renderers.md b/packages/website/docs/advanced/renderers.md index 4e494b357..9101ba858 100644 --- a/packages/website/docs/advanced/renderers.md +++ b/packages/website/docs/advanced/renderers.md @@ -71,6 +71,7 @@ Some more programs are also exposed, but as they carry more complexity, they are - [**`@sigma/node-image`**](https://www.npmjs.com/package/@sigma/node-image): This package exposes a factory to create a program that operates similarly to `NodeCircleProgram`, but filling the circles with images using a texture atlas. - [**`@sigma/node-border`**](https://www.npmjs.com/package/@sigma/node-border): This package exposes a factory to create a program that operates similarly to `NodeCircleProgram`, but drawing concentric discs. - [**`@sigma/node-piechart`**](https://www.npmjs.com/package/@sigma/node-piechart): This package exposes a factory to create a program that operates similarly to `NodeCircleProgram`, but drawing nodes as pie-charts. +- [**`@sigma/node-square`**](https://www.npmjs.com/package/@sigma/node-square): This package exposes a simple program that renders nodes as squares. - [**`@sigma/edge-curve`**](https://www.npmjs.com/package/@sigma/edge-curve): This package exposes an edge renderer that draw edges as curves. ## Additional layers diff --git a/packages/website/docusaurus.config.js b/packages/website/docusaurus.config.js index 89d9928e3..e8e5a5eed 100644 --- a/packages/website/docusaurus.config.js +++ b/packages/website/docusaurus.config.js @@ -42,10 +42,13 @@ const config = { "../sigma/src/settings.ts", "../sigma/src/rendering/index.ts", "../sigma/src/utils/index.ts", + "../layer-leaflet/src/index.ts", + "../layer-maplibre/src/index.ts", "../layer-webgl/src/index.ts", "../node-border/src/index.ts", "../node-image/src/index.ts", "../node-piechart/src/index.ts", + "../node-square/src/index.ts", "../edge-curve/src/index.ts", ], watch: true, diff --git a/tsconfig.json b/tsconfig.json index 8000327f3..67abcf909 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,13 @@ { "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" } ], "watchOptions": {