├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ └── release.yml ├── docs ├── assets │ ├── logo.png │ ├── graph-example.png │ ├── view-map-example.png │ ├── view-default-fixed.png │ └── view-default-simulated.png ├── view-map.md ├── data.md └── view-default.md ├── .eslintignore ├── src ├── renderer │ ├── canvas │ │ ├── edge │ │ │ ├── index.ts │ │ │ ├── shared.ts │ │ │ ├── types │ │ │ │ ├── edge-straight.ts │ │ │ │ ├── edge-loopback.ts │ │ │ │ └── edge-curved.ts │ │ │ └── base.ts │ │ ├── label.ts │ │ ├── node.ts │ │ ├── shapes.ts │ │ └── canvas-renderer.ts │ ├── factory.ts │ ├── webgl │ │ └── webgl-renderer.ts │ └── shared.ts ├── simulator │ ├── types │ │ ├── web-worker-simulator │ │ │ ├── index.ts │ │ │ ├── message │ │ │ │ ├── worker-payload.ts │ │ │ │ ├── worker-output.ts │ │ │ │ └── worker-input.ts │ │ │ ├── process.worker.ts │ │ │ └── simulator.ts │ │ └── main-thread-simulator.ts │ ├── index.ts │ ├── factory.ts │ └── shared.ts ├── common │ ├── circle.ts │ ├── index.ts │ ├── position.ts │ ├── rectangle.ts │ ├── distance.ts │ └── color.ts ├── utils │ ├── math.utils.ts │ ├── array.utils.ts │ ├── function.utils.ts │ ├── html.utils.ts │ ├── type.utils.ts │ ├── object.utils.ts │ ├── entity.utils.ts │ └── emitter.utils.ts ├── models │ ├── state.ts │ ├── style.ts │ ├── topology.ts │ └── strategy.ts ├── views │ ├── index.ts │ └── shared.ts ├── exceptions.ts ├── index.ts ├── orb.ts ├── services │ └── images.ts └── events.ts ├── .husky ├── pre-commit └── commit-msg ├── tsconfig.eslint.json ├── .npmignore ├── .gitignore ├── .releaserc ├── .commitlintrc.json ├── tsconfig.json ├── test └── utils │ ├── html.utils.spec.ts │ └── entity.utils.spec.ts ├── .eslintrc ├── examples ├── example-simple-graph.html ├── index.html ├── example-fixed-coordinates-graph.html ├── example-graph-on-map.html ├── example-custom-styled-graph.html ├── playground.html ├── example-graph-data-changes.html └── example-graph-events.html ├── CHANGELOG.md ├── webpack.config.js ├── CONTRIBUTING.md ├── package.json └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cizl @tonilastre 2 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/memgraph/orb/HEAD/docs/assets/logo.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | node_modules/ 4 | examples/ 5 | webpack.config.js 6 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/index.ts: -------------------------------------------------------------------------------- 1 | export { drawEdge, IEdgeDrawOptions } from './base'; 2 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/index.ts: -------------------------------------------------------------------------------- 1 | export { WebWorkerSimulator } from './simulator'; 2 | -------------------------------------------------------------------------------- /docs/assets/graph-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/memgraph/orb/HEAD/docs/assets/graph-example.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | [ -n "$CI" ] && exit 0 3 | . "$(dirname "$0")/_/husky.sh" 4 | 5 | npm run lint -------------------------------------------------------------------------------- /docs/assets/view-map-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/memgraph/orb/HEAD/docs/assets/view-map-example.png -------------------------------------------------------------------------------- /docs/assets/view-default-fixed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/memgraph/orb/HEAD/docs/assets/view-default-fixed.png -------------------------------------------------------------------------------- /docs/assets/view-default-simulated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/memgraph/orb/HEAD/docs/assets/view-default-simulated.png -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts", 5 | "test/**/*.ts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | [ -n "$CI" ] && exit 0 3 | . "$(dirname -- "$0")/_/husky.sh" 4 | 5 | npx --no-install commitlint --edit $1 6 | -------------------------------------------------------------------------------- /src/simulator/index.ts: -------------------------------------------------------------------------------- 1 | export { SimulatorFactory } from './factory'; 2 | export { ISimulator, ISimulationNode, ISimulationEdge } from './shared'; 3 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/message/worker-payload.ts: -------------------------------------------------------------------------------- 1 | export type IWorkerPayload = K extends void ? { type: T } : { type: T; data: K }; 2 | -------------------------------------------------------------------------------- /src/common/circle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 2D circle with x, y coordinates and radius r. 3 | */ 4 | export interface ICircle { 5 | x: number; 6 | y: number; 7 | radius: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/math.utils.ts: -------------------------------------------------------------------------------- 1 | export const getThrottleMsFromFPS = (fps: number): number => { 2 | const validFps = Math.max(fps, 1); 3 | return Math.round(1000 / validFps); 4 | }; 5 | -------------------------------------------------------------------------------- /src/models/state.ts: -------------------------------------------------------------------------------- 1 | // Enum is dismissed so user can define custom additional events (numbers) 2 | export const GraphObjectState = { 3 | NONE: 0, 4 | SELECTED: 1, 5 | HOVERED: 2, 6 | }; 7 | -------------------------------------------------------------------------------- /src/views/index.ts: -------------------------------------------------------------------------------- 1 | export { DefaultView, IDefaultViewSettings } from './default-view'; 2 | export { MapView, IMapViewSettings } from './map-view'; 3 | export { IOrbView, IOrbViewFactory, IOrbViewContext } from './shared'; 4 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export { ICircle } from './circle'; 2 | export { Color, IColorRGB } from './color'; 3 | export { getDistanceToLine } from './distance'; 4 | export { IPosition, isEqualPosition } from './position'; 5 | export { IRectangle, isPointInRectangle } from './rectangle'; 6 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | export class OrbError extends Error { 2 | message: string; 3 | 4 | constructor(message: string) { 5 | super(message); 6 | 7 | this.message = message; 8 | 9 | Object.setPrototypeOf(this, new.target.prototype); 10 | this.name = this.constructor.name; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/shared.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from '../../../common'; 2 | 3 | export interface IBorderPosition extends IPosition { 4 | t: number; 5 | } 6 | 7 | export interface IEdgeArrow { 8 | point: IBorderPosition; 9 | core: IPosition; 10 | angle: number; 11 | length: number; 12 | } 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .git/ 3 | .gitignore 4 | .gitattributes 5 | .github/ 6 | .gitmodules 7 | 8 | .out/ 9 | .idea/ 10 | 11 | .eslintrc 12 | .eslintignore 13 | .DS_Store 14 | .releaserc 15 | 16 | coverage/ 17 | 18 | node_modules/ 19 | npm-shrinkwrap.json 20 | npm-debug.log 21 | .npmrc 22 | 23 | tsconfig.* 24 | 25 | examples 26 | test 27 | docs 28 | src 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled output 2 | dist/ 3 | 4 | # Dependencies 5 | node_modules/ 6 | 7 | # IDEs and editors 8 | .idea/ 9 | 10 | # Optional npm cache directory 11 | .npm 12 | 13 | # Optional eslint cache 14 | .eslintcache 15 | 16 | # TypeScript cache 17 | *.tsbuildinfo 18 | 19 | # Misc 20 | coverage/ 21 | npm-debug.log 22 | 23 | # MacOS 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Other directories 29 | .out/ 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build_and_test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: "16.x" 13 | 14 | - name: 'Install' 15 | run: npm ci 16 | - name: 'Lint' 17 | run: npm run lint 18 | - name: 'Build' 19 | run: npm run build:release 20 | - name: 'Test' 21 | run: npm run test 22 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@semantic-release/commit-analyzer", { 4 | "preset": "eslint" 5 | }], 6 | ["@semantic-release/release-notes-generator", { 7 | "preset": "eslint" 8 | }], 9 | "@semantic-release/changelog", 10 | "@semantic-release/npm", 11 | '@semantic-release/github', 12 | ["@semantic-release/git", { 13 | "assets": ["package.json", "CHANGELOG.md"], 14 | "message": "Chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 15 | }], 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/array.utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copies input array into a new array. Doesn't do deep copy. 3 | * 4 | * The following implementation is faster: 5 | * - ~ 6x than `array.map(v => v)` 6 | * - ~15x than `[...array] 7 | * 8 | * @param {Array} array Input array 9 | * @return {Array} Copied array 10 | */ 11 | export const copyArray = (array: Array): Array => { 12 | const newArray = new Array(array.length); 13 | for (let i = 0; i < array.length; i++) { 14 | newArray[i] = array[i]; 15 | } 16 | return newArray; 17 | }; 18 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "subject-case": [0, "never"], 5 | "type-case": [ 6 | 2, 7 | "always", 8 | "pascal-case" 9 | ], 10 | "type-empty": [ 11 | 2, 12 | "never" 13 | ], 14 | "type-enum": [ 15 | 2, 16 | "always", 17 | [ 18 | "Fix", 19 | "Update", 20 | "New", 21 | "Breaking", 22 | "Docs", 23 | "Build", 24 | "Upgrade", 25 | "Chore" 26 | ] 27 | ] 28 | } 29 | } -------------------------------------------------------------------------------- /src/common/position.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 2D point with x, y coordinates. 3 | */ 4 | export interface IPosition { 5 | x: number; 6 | y: number; 7 | } 8 | 9 | /** 10 | * Checks if two x, y positions are equal. 11 | * 12 | * @param {IPosition} position1 Position 13 | * @param {IPosition} position2 Position 14 | * @return {boolean} True if positions are equal (x and y are equal), otherwise false 15 | */ 16 | export const isEqualPosition = (position1?: IPosition, position2?: IPosition): boolean => { 17 | return !!position1 && !!position2 && position1.x === position2.x && position1.y === position2.y; 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | if: github.repository == 'memgraph/orb' 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: "16.x" 17 | 18 | - name: 'Install' 19 | run: npm ci 20 | - name: 'Lint' 21 | run: npm run lint 22 | - name: 'Build' 23 | run: npm run build:release 24 | - name: 'Release' 25 | run: npm run release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "lib": ["DOM", "ES6"], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictBindCallApply": true, 17 | "strictPropertyInitialization": true, 18 | "noImplicitThis": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "skipLibCheck": true 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/simulator/factory.ts: -------------------------------------------------------------------------------- 1 | import { ISimulator } from './shared'; 2 | import { MainThreadSimulator } from './types/main-thread-simulator'; 3 | import { WebWorkerSimulator } from './types/web-worker-simulator/simulator'; 4 | 5 | export class SimulatorFactory { 6 | static getSimulator(): ISimulator { 7 | try { 8 | if (typeof Worker !== 'undefined') { 9 | return new WebWorkerSimulator(); 10 | } 11 | throw new Error('WebWorkers are unavailable in your environment.'); 12 | } catch (err) { 13 | console.error( 14 | 'Could not create simulator in a WebWorker context. All calculations will be done in the main thread.', 15 | err, 16 | ); 17 | return new MainThreadSimulator(); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Orb, IOrbSettings } from './orb'; 2 | export { OrbEventType } from './events'; 3 | export { OrbError } from './exceptions'; 4 | export { 5 | DefaultView, 6 | MapView, 7 | IOrbView, 8 | IOrbViewContext, 9 | IOrbViewFactory, 10 | IMapViewSettings, 11 | IDefaultViewSettings, 12 | } from './views'; 13 | export { IGraph, IGraphData, INodeFilter, IEdgeFilter } from './models/graph'; 14 | export { GraphObjectState } from './models/state'; 15 | export { INode, INodeBase, INodePosition, INodeStyle, isNode, NodeShapeType } from './models/node'; 16 | export { IEdge, IEdgeBase, IEdgePosition, IEdgeStyle, isEdge, EdgeType } from './models/edge'; 17 | export { IGraphStyle, getDefaultGraphStyle } from './models/style'; 18 | export { ICircle, IPosition, IRectangle, Color, IColorRGB } from './common'; 19 | -------------------------------------------------------------------------------- /src/common/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from './position'; 2 | 3 | /** 4 | * 2D rectangle with top left point (x, y), width and height. 5 | */ 6 | export interface IRectangle { 7 | x: number; 8 | y: number; 9 | width: number; 10 | height: number; 11 | } 12 | 13 | /** 14 | * Checks if the point (x, y) is in the rectangle. 15 | * 16 | * @param {IRectangle} rectangle Rectangle 17 | * @param {IPosition} point Point (x, y) 18 | * @return {boolean} True if the point (x, y) is in the rectangle, otherwise false 19 | */ 20 | export const isPointInRectangle = (rectangle: IRectangle, point: IPosition): boolean => { 21 | const endX = rectangle.x + rectangle.width; 22 | const endY = rectangle.y + rectangle.height; 23 | return point.x >= rectangle.x && point.x <= endX && point.y >= rectangle.y && point.y <= endY; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/function.utils.ts: -------------------------------------------------------------------------------- 1 | export const throttle = (fn: Function, waitMs = 300) => { 2 | let isInThrottle = false; 3 | let lastTimer: ReturnType; 4 | let lastTimestamp: number = Date.now(); 5 | 6 | return function () { 7 | // eslint-disable-next-line prefer-rest-params 8 | const args = arguments; 9 | const now = Date.now(); 10 | 11 | if (!isInThrottle) { 12 | fn(...args); 13 | lastTimestamp = now; 14 | isInThrottle = true; 15 | return; 16 | } 17 | 18 | clearTimeout(lastTimer); 19 | const timerWaitMs = Math.max(waitMs - (now - lastTimestamp), 0); 20 | 21 | lastTimer = setTimeout(() => { 22 | if (now - lastTimestamp >= waitMs) { 23 | fn(...args); 24 | lastTimestamp = now; 25 | } 26 | }, timerWaitMs); 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/views/shared.ts: -------------------------------------------------------------------------------- 1 | import { INodeBase } from '../models/node'; 2 | import { IEdgeBase } from '../models/edge'; 3 | import { IGraph } from '../models/graph'; 4 | import { OrbEmitter } from '../events'; 5 | import { IEventStrategy } from '../models/strategy'; 6 | 7 | export interface IOrbView { 8 | isInitiallyRendered(): boolean; 9 | getSettings(): S; 10 | setSettings(settings: Partial): void; 11 | render(onRendered?: () => void): void; 12 | recenter(onRendered?: () => void): void; 13 | destroy(): void; 14 | } 15 | 16 | export interface IOrbViewContext { 17 | container: HTMLElement; 18 | graph: IGraph; 19 | events: OrbEmitter; 20 | strategy: IEventStrategy; 21 | } 22 | 23 | export type IOrbViewFactory = ( 24 | context: IOrbViewContext, 25 | ) => IOrbView; 26 | -------------------------------------------------------------------------------- /src/renderer/factory.ts: -------------------------------------------------------------------------------- 1 | import { CanvasRenderer } from './canvas/canvas-renderer'; 2 | import { IRenderer, IRendererSettings, RendererType } from './shared'; 3 | import { WebGLRenderer } from './webgl/webgl-renderer'; 4 | import { OrbError } from '../exceptions'; 5 | import { INodeBase } from '../models/node'; 6 | import { IEdgeBase } from '../models/edge'; 7 | 8 | export class RendererFactory { 9 | static getRenderer( 10 | canvas: HTMLCanvasElement, 11 | type: RendererType = RendererType.CANVAS, 12 | settings?: Partial, 13 | ): IRenderer { 14 | if (type === RendererType.WEBGL) { 15 | const context = canvas.getContext('webgl2'); 16 | if (!context) { 17 | throw new OrbError('Failed to create WebGL context.'); 18 | } 19 | return new WebGLRenderer(context, settings); 20 | } 21 | 22 | const context = canvas.getContext('2d'); 23 | if (!context) { 24 | throw new OrbError('Failed to create Canvas context.'); 25 | } 26 | return new CanvasRenderer(context, settings); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/common/distance.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from './position'; 2 | 3 | /** 4 | * Calculate the distance between a point (x3,y3) and a line segment from (x1,y1) to (x2,y2). 5 | * @see {@link http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment} 6 | * 7 | * @param {IPosition} startLinePoint Line start position 8 | * @param {IPosition} endLinePoint Line end position 9 | * @param {IPosition} point Target position 10 | * @return {number} Distance between the point and the line 11 | */ 12 | export const getDistanceToLine = (startLinePoint: IPosition, endLinePoint: IPosition, point: IPosition): number => { 13 | const dx = endLinePoint.x - startLinePoint.x; 14 | const dy = endLinePoint.y - startLinePoint.y; 15 | 16 | // Percentage of the line segment from the line start that is closest to the target point 17 | let lineSegment = ((point.x - startLinePoint.x) * dx + (point.y - startLinePoint.y) * dy) / (dx * dx + dy * dy); 18 | if (lineSegment > 1) { 19 | lineSegment = 1; 20 | } 21 | if (lineSegment < 0) { 22 | lineSegment = 0; 23 | } 24 | 25 | // Point on the line closest to the target point and its distance 26 | const newLinePointX = startLinePoint.x + lineSegment * dx; 27 | const newLinePointY = startLinePoint.y + lineSegment * dy; 28 | const pdx = newLinePointX - point.x; 29 | const pdy = newLinePointY - point.y; 30 | 31 | return Math.sqrt(pdx * pdx + pdy * pdy); 32 | }; 33 | -------------------------------------------------------------------------------- /src/models/style.ts: -------------------------------------------------------------------------------- 1 | import { IEdge, IEdgeBase, IEdgeStyle } from './edge'; 2 | import { INode, INodeBase, INodeStyle } from './node'; 3 | import { Color } from '../common'; 4 | 5 | const LABEL_PROPERTY_NAMES = ['label', 'name']; 6 | 7 | export const DEFAULT_NODE_STYLE: INodeStyle = { 8 | size: 5, 9 | color: new Color('#1d87c9'), 10 | }; 11 | 12 | export const DEFAULT_EDGE_STYLE: IEdgeStyle = { 13 | color: new Color('#ababab'), 14 | width: 0.3, 15 | }; 16 | 17 | export interface IGraphStyle { 18 | getNodeStyle(node: INode): INodeStyle | undefined; 19 | getEdgeStyle(edge: IEdge): IEdgeStyle | undefined; 20 | } 21 | 22 | export const getDefaultGraphStyle = (): IGraphStyle => { 23 | return { 24 | getNodeStyle(node: INode): INodeStyle { 25 | return { ...DEFAULT_NODE_STYLE, label: getPredefinedLabel(node) }; 26 | }, 27 | getEdgeStyle(edge: IEdge): IEdgeStyle { 28 | return { ...DEFAULT_EDGE_STYLE, label: getPredefinedLabel(edge) }; 29 | }, 30 | }; 31 | }; 32 | 33 | const getPredefinedLabel = ( 34 | obj: INode | IEdge, 35 | ): string | undefined => { 36 | for (let i = 0; i < LABEL_PROPERTY_NAMES.length; i++) { 37 | const value = (obj.data as any)[LABEL_PROPERTY_NAMES[i]]; 38 | if (value !== undefined && value !== null) { 39 | return `${value}`; 40 | } 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /test/utils/html.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { isCollapsedDimension } from '../../src/utils/html.utils'; 2 | 3 | describe('html.utils', () => { 4 | test('should match collapsed style dimensions regex', () => { 5 | expect(isCollapsedDimension(null)).toBe(true); 6 | expect(isCollapsedDimension(undefined)).toBe(true); 7 | expect(isCollapsedDimension('')).toBe(true); 8 | expect(isCollapsedDimension('0')).toBe(true); 9 | expect(isCollapsedDimension('0000')).toBe(true); 10 | expect(isCollapsedDimension(' 0 ')).toBe(true); 11 | expect(isCollapsedDimension('0px')).toBe(true); 12 | expect(isCollapsedDimension(' 00 px ')).toBe(true); 13 | expect(isCollapsedDimension('0Rem')).toBe(true); 14 | expect(isCollapsedDimension(' 0 rem ')).toBe(true); 15 | expect(isCollapsedDimension('0em')).toBe(true); 16 | expect(isCollapsedDimension(' 00 em ')).toBe(true); 17 | expect(isCollapsedDimension('0vH')).toBe(true); 18 | expect(isCollapsedDimension(' 0 vh ')).toBe(true); 19 | expect(isCollapsedDimension('0vw')).toBe(true); 20 | expect(isCollapsedDimension(' 00 vw ')).toBe(true); 21 | expect(isCollapsedDimension('px')).toBe(false); 22 | expect(isCollapsedDimension('01px')).toBe(false); 23 | expect(isCollapsedDimension(' 010 rem ')).toBe(false); 24 | expect(isCollapsedDimension(' 0 px em rem')).toBe(false); 25 | expect(isCollapsedDimension('undefined')).toBe(false); 26 | expect(isCollapsedDimension('null')).toBe(false); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2018": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "google", 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:jest/recommended" 14 | ], 15 | "globals": { 16 | "Atomics": "readonly", 17 | "SharedArrayBuffer": "readonly" 18 | }, 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaVersion": 2018, 22 | "sourceType": "module", 23 | "project": "./tsconfig.eslint.json" 24 | }, 25 | "plugins": [ 26 | "@typescript-eslint", 27 | "prettier", 28 | "jest" 29 | ], 30 | "rules": { 31 | "prettier/prettier": [ 32 | "error", 33 | { 34 | "singleQuote": true, 35 | "trailingComma": "all", 36 | "printWidth": 120 37 | } 38 | ], 39 | "brace-style": ["error", "1tbs"], 40 | "curly": ["error", "all"], 41 | "require-jsdoc": "off", 42 | "max-len": [ 43 | "error", 44 | { 45 | "code": 120, 46 | "tabWidth": 2, 47 | "ignoreUrls": true, 48 | "ignoreStrings": true, 49 | "ignoreComments": true, 50 | "ignoreTemplateLiterals": true 51 | } 52 | ], 53 | "@typescript-eslint/no-explicit-any": "off", 54 | "@typescript-eslint/explicit-function-return-type": "off", 55 | "@typescript-eslint/ban-ts-comment": "off", 56 | "@typescript-eslint/ban-types": "off", 57 | "jest/no-standalone-expect": "off", 58 | "jest/expect-expect": "off", 59 | "jest/no-commented-out-tests": "off", 60 | "jest/no-done-callback": "off" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /examples/example-simple-graph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Simple graph on default canvas 7 | 8 | 9 | 15 | 16 |
17 |

Example 1 - Basic

18 |

Renders a simple graph on the default canvas.

19 | 20 | 24 |
25 |
26 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.3 2 | 3 | * Fix: Color class is incorrectly converting hex to rgb (#85) (fixes #84) - by @rgoewedky 4 | 5 | ### Fix 6 | 7 | * Fix: resizeObserver is not unobserved (#82) (fixes #81) - by @markusnissl 8 | 9 | ## 0.4.2 10 | 11 | ### Fix 12 | 13 | * Fix: resizeObserver is not unobserved (#82) (fixes #81) - by @markusnissl 14 | 15 | ## 0.4.1 16 | 17 | ### Fix 18 | 19 | * Add the correct sort for the edge zIndex (#66) 20 | * Fix the spelling issues in the project (#65) - by @jsoref 21 | 22 | ## 0.4.0 23 | 24 | ### New 25 | 26 | * Add canvas background color (#55) 27 | * Add zIndex style property (#53) 28 | 29 | ## 0.3.0 30 | 31 | ### New 32 | 33 | * Added support for double click events (#50) - by @shashankshukla96 34 | 35 | ## 0.2.0 36 | 37 | ### New 38 | 39 | * Add support for context menu events (#46) - by @shashankshukla96 40 | 41 | ## 0.1.5 42 | 43 | ### Fix 44 | 45 | * Fix web worker build with parcel (#36) - by @jondlm 46 | 47 | ## 0.1.4 48 | 49 | ### Fix 50 | 51 | * Fix: Remove render callback after first usage in simulation end (#31) 52 | 53 | ## 0.1.3 54 | 55 | ### Fix 56 | 57 | * Fix: Add throttle render to renderer via fps settings (#29) 58 | 59 | ## 0.1.2 60 | 61 | ### Fix 62 | 63 | * Fix physics behavior (#26) 64 | 65 | ### Chore 66 | 67 | * Set dimensions on parent element when undefined (#24) 68 | * Preserve graph container children (#24) 69 | * Apply Emitter in simulator and fix cleanup when terminated (#25) 70 | * Refactor naming in simulator (#25) 71 | 72 | ## 0.1.1 73 | 74 | ### Fix 75 | 76 | * Fix undefined subject on mouse hover event (#20) 77 | 78 | ## 0.1.0 (2022-09-14) 79 | 80 | ### Fix 81 | 82 | * Fix image loading on node style properties (#14) 83 | 84 | ### Chore 85 | 86 | * Add pre commit hook (#11) 87 | * Fix docs and general orb API issues (#13) 88 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const name = 'orb'; 4 | 5 | const commonConfiguration = { 6 | entry: './src/index.ts', 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | use: 'ts-loader', 12 | exclude: '/node_modules/', 13 | }, 14 | ], 15 | }, 16 | resolve: { 17 | extensions: ['.tsx', '.ts', '.js'], 18 | }, 19 | output: { 20 | chunkFilename(pathData) { 21 | return pathData.chunk.name === 'process.worker' ? `${name}.worker.js` : `${name}.worker.vendor.js`; 22 | }, 23 | filename: `${name}.js`, 24 | path: path.resolve(__dirname, 'dist/browser'), 25 | library: { 26 | name: 'Orb', 27 | type: 'umd' 28 | } 29 | }, 30 | devServer: { 31 | static: { 32 | directory: path.join(__dirname, '/examples/'), 33 | }, 34 | compress: true, 35 | port: 9000, 36 | }, 37 | plugins: [ 38 | new CopyWebpackPlugin({ 39 | patterns: [ 40 | { 41 | from: './examples', 42 | } 43 | ] 44 | }) 45 | ], 46 | performance: { 47 | hints: false, 48 | maxEntrypointSize: 512000, // 500kb 49 | maxAssetSize: 512000, // 500kb 50 | }, 51 | }; 52 | 53 | const developmentConfiguration = { 54 | ...commonConfiguration, 55 | mode: 'development', 56 | devtool: 'inline-source-map', 57 | }; 58 | 59 | const productionConfiguration = { 60 | ...commonConfiguration, 61 | mode: 'production', 62 | output: { 63 | ...commonConfiguration.output, 64 | chunkFilename(pathData) { 65 | return pathData.chunk.name === 'process.worker' ? `${name}.worker.min.js` : `${name}.worker.vendor.min.js`; 66 | }, 67 | filename: `${name}.min.js`, 68 | }, 69 | } 70 | 71 | module.exports = [developmentConfiguration, productionConfiguration]; 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Orb 2 | 3 | ## Commits 4 | 5 | Our commit message format is as follows: 6 | 7 | ``` 8 | Tag: Short description (fixes #1234) 9 | 10 | Longer description here if necessary 11 | ``` 12 | 13 | The first line of the commit message (the summary) must have a specific format. 14 | This format is checked by our build tools. 15 | 16 | The `Tag` is one of the following: 17 | 18 | * `Fix` - for a bug fix. 19 | * `Update` - either for a backwards-compatible enhancement or for a rule change 20 | that adds reported problems. 21 | * `New` - implemented a new feature. 22 | * `Breaking` - for a backwards-incompatible enhancement or feature. 23 | * `Docs` - changes to documentation only. 24 | * `Build` - changes to build process only. 25 | * `Upgrade` - for a dependency upgrade. 26 | * `Chore` - for refactoring, adding tests, etc. (anything that isn't user-facing). 27 | 28 | The message summary should be a one-sentence description of the change, and it must 29 | be 72 characters in length or shorter. If the pull request addresses an issue, then 30 | the issue number should be mentioned at the end. If the commit doesn't completely fix 31 | the issue, then use `(refs #1234)` instead of `(fixes #1234)`. 32 | 33 | Here are some good commit message summary examples: 34 | 35 | ``` 36 | Build: Update Travis to only test Node 0.10 (refs #734) 37 | Fix: Semi rule incorrectly flagging extra semicolon (fixes #840) 38 | Upgrade: Esprima to 1.2, switch to using comment attachment (fixes #730) 39 | ``` 40 | 41 | The commit message format is important because these messages are used to create 42 | a changelog for each release. The tag and issue number help to create more consistent 43 | and useful changelogs. 44 | 45 | [Check ESLint Convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint) 46 | 47 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/message/worker-output.ts: -------------------------------------------------------------------------------- 1 | import { ISimulationNode, ISimulationEdge } from '../../../shared'; 2 | import { IWorkerPayload } from './worker-payload'; 3 | import { ID3SimulatorEngineSettings } from '../../../engine/d3-simulator-engine'; 4 | 5 | export enum WorkerOutputType { 6 | SIMULATION_START = 'simulation-start', 7 | SIMULATION_PROGRESS = 'simulation-progress', 8 | SIMULATION_END = 'simulation-end', 9 | NODE_DRAG = 'node-drag', 10 | NODE_DRAG_END = 'node-drag-end', 11 | SETTINGS_UPDATE = 'settings-update', 12 | } 13 | 14 | type IWorkerOutputSimulationStartPayload = IWorkerPayload; 15 | 16 | type IWorkerOutputSimulationProgressPayload = IWorkerPayload< 17 | WorkerOutputType.SIMULATION_PROGRESS, 18 | { 19 | nodes: ISimulationNode[]; 20 | edges: ISimulationEdge[]; 21 | progress: number; 22 | } 23 | >; 24 | 25 | type IWorkerOutputSimulationEndPayload = IWorkerPayload< 26 | WorkerOutputType.SIMULATION_END, 27 | { 28 | nodes: ISimulationNode[]; 29 | edges: ISimulationEdge[]; 30 | } 31 | >; 32 | 33 | type IWorkerOutputNodeDragPayload = IWorkerPayload< 34 | WorkerOutputType.NODE_DRAG, 35 | { 36 | nodes: ISimulationNode[]; 37 | edges: ISimulationEdge[]; 38 | } 39 | >; 40 | 41 | type IWorkerOutputNodeDragEndPayload = IWorkerPayload< 42 | WorkerOutputType.NODE_DRAG_END, 43 | { 44 | nodes: ISimulationNode[]; 45 | edges: ISimulationEdge[]; 46 | } 47 | >; 48 | 49 | type IWorkerOutputSettingsUpdatePayload = IWorkerPayload< 50 | WorkerOutputType.SETTINGS_UPDATE, 51 | { 52 | settings: ID3SimulatorEngineSettings; 53 | } 54 | >; 55 | 56 | export type IWorkerOutputPayload = 57 | | IWorkerOutputSimulationStartPayload 58 | | IWorkerOutputSimulationProgressPayload 59 | | IWorkerOutputSimulationEndPayload 60 | | IWorkerOutputNodeDragPayload 61 | | IWorkerOutputNodeDragEndPayload 62 | | IWorkerOutputSettingsUpdatePayload; 63 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Examples 7 | 8 | 21 | 22 |

Orb Examples

23 |
24 |

  • Example 1 - Basic
  • 25 |

    Renders a simple graph on the default canvas.

    26 |
    27 |
    28 |

  • Example 2 - Basic + Custom default style
  • 29 |

    30 | Renders a simple graph with a custom default style (color, size, border, 31 | text) for nodes and edges. 32 |

    33 |
    34 |
    35 |

  • Example 3 - Fixed coordinates
  • 36 |

    37 | Renders a simple graph with fixed node coordinates where Orb won't 38 | simulate and position the nodes. 39 |

    40 |
    41 |
    42 |

  • Example 4 - Events
  • 43 |

    44 | Renders a simple graph with few event listeners: Each graph simulation step 45 | is shown with progress indicator. On node click, node hover, and edge click, 46 | an event will be logged in the console with a message showing clicked/hovered 47 | node or edge. 48 |

    49 |
    50 |
    51 |

  • Example 5 - Dynamics
  • 52 |

    53 | Renders a simple graph to show graph dynamics: adding, updating, and removing 54 | nodes and edges. In intervals of 3 seconds, 1 new node and 1 new edge will be 55 | added to the graph. Node will be removed from the graph on node click event. 56 |

    57 |
    58 |
    59 |

  • Example 6 - Map
  • 60 |

    61 | Renders a graph with map background. Each node needs to provide latitude and 62 | longitude values. 63 |

    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /src/utils/html.utils.ts: -------------------------------------------------------------------------------- 1 | export const setupContainer = (container: HTMLElement, areCollapsedDimensionsAllowed = false) => { 2 | container.style.position = 'relative'; 3 | const style = getComputedStyle(container); 4 | if (!style.display) { 5 | container.style.display = 'block'; 6 | console.warn("[Orb] Graph container doesn't have defined 'display' property. Setting 'display' to 'block'..."); 7 | } 8 | if (!areCollapsedDimensionsAllowed && isCollapsedDimension(style.width)) { 9 | container.style.width = '100%'; 10 | 11 | // Check if the dimension is still collapsed. 12 | // This means that a percentage value has no effect 13 | // since the container parent also doesn't have defined height/position. 14 | if (isCollapsedDimension(getComputedStyle(container).width)) { 15 | container.style.width = '400px'; 16 | console.warn( 17 | "[Orb] The graph container element and its parent don't have defined width properties.", 18 | 'If you are using percentage values,', 19 | 'please make sure that the parent element of the graph container has a defined position and width.', 20 | "Setting the width of the graph container to an arbitrary value of '400px'...", 21 | ); 22 | } else { 23 | console.warn("[Orb] The graph container element doesn't have defined width. Setting width to 100%..."); 24 | } 25 | } 26 | if (!areCollapsedDimensionsAllowed && isCollapsedDimension(style.height)) { 27 | container.style.height = '100%'; 28 | if (isCollapsedDimension(getComputedStyle(container).height)) { 29 | container.style.height = '400px'; 30 | console.warn( 31 | "[Orb] The graph container element and its parent don't have defined height properties.", 32 | 'If you are using percentage values,', 33 | 'please make sure that the parent element of the graph container has a defined position and height.', 34 | "Setting the height of the graph container to an arbitrary value of '400px'...", 35 | ); 36 | } else { 37 | console.warn("[Orb] Graph container doesn't have defined height. Setting height to 100%..."); 38 | } 39 | } 40 | }; 41 | 42 | export const collapsedDimensionRegex = /^\s*0+\s*(?:px|rem|em|vh|vw)?\s*$/i; 43 | 44 | export const isCollapsedDimension = (dimension: string | null | undefined) => { 45 | if (dimension === null || dimension === undefined || dimension === '') { 46 | return true; 47 | } 48 | 49 | return collapsedDimensionRegex.test(dimension); 50 | }; 51 | -------------------------------------------------------------------------------- /src/orb.ts: -------------------------------------------------------------------------------- 1 | import { Graph, IGraph } from './models/graph'; 2 | import { INodeBase } from './models/node'; 3 | import { IEdgeBase } from './models/edge'; 4 | import { DefaultView, IOrbViewFactory, IOrbView, IOrbViewContext } from './views'; 5 | import { getDefaultEventStrategy, IEventStrategy } from './models/strategy'; 6 | import { OrbEmitter } from './events'; 7 | import { getDefaultGraphStyle } from './models/style'; 8 | 9 | export interface IOrbSettings { 10 | view: IOrbViewFactory; 11 | strategy: IEventStrategy; 12 | } 13 | 14 | // TODO: Change the Orb API to be a single view instance to support non-any 15 | // @see: https://stackoverflow.com/questions/73429628/how-to-setup-typescript-generics-in-class-constructors-and-functions 16 | export class Orb { 17 | private _view: IOrbView; 18 | private readonly _events: OrbEmitter; 19 | private readonly _graph: IGraph; 20 | private readonly _context: IOrbViewContext; 21 | 22 | constructor(private container: HTMLElement, settings?: Partial>) { 23 | this._events = new OrbEmitter(); 24 | this._graph = new Graph(undefined, { 25 | onLoadedImages: () => { 26 | // Not to call render() before user's .render() 27 | if (this._view.isInitiallyRendered()) { 28 | this._view.render(); 29 | } 30 | }, 31 | }); 32 | this._graph.setDefaultStyle(getDefaultGraphStyle()); 33 | 34 | this._context = { 35 | container: this.container, 36 | graph: this._graph, 37 | events: this._events, 38 | strategy: settings?.strategy ?? getDefaultEventStrategy(), 39 | }; 40 | 41 | if (settings?.view) { 42 | this._view = settings.view(this._context); 43 | } else { 44 | this._view = new DefaultView(this._context); 45 | } 46 | } 47 | 48 | get data(): IGraph { 49 | return this._graph; 50 | } 51 | 52 | get view(): IOrbView { 53 | return this._view; 54 | } 55 | 56 | get events(): OrbEmitter { 57 | return this._events; 58 | } 59 | 60 | setView(factory: IOrbViewFactory) { 61 | // Reset the existing graph in case of switching between different view types. 62 | if (this._graph.getNodeCount() > 0) { 63 | this._graph.clearPositions(); 64 | } 65 | 66 | if (this._view) { 67 | this._view.destroy(); 68 | } 69 | this._view = factory(this._context); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/renderer/webgl/webgl-renderer.ts: -------------------------------------------------------------------------------- 1 | import { zoomIdentity, ZoomTransform } from 'd3-zoom'; 2 | import { INodeBase } from '../../models/node'; 3 | import { IEdgeBase } from '../../models/edge'; 4 | import { IGraph } from '../../models/graph'; 5 | import { IPosition, IRectangle } from '../../common'; 6 | import { Emitter } from '../../utils/emitter.utils'; 7 | import { 8 | DEFAULT_RENDERER_HEIGHT, 9 | DEFAULT_RENDERER_SETTINGS, 10 | DEFAULT_RENDERER_WIDTH, 11 | IRenderer, 12 | RendererEvents as RE, 13 | IRendererSettings, 14 | } from '../shared'; 15 | import { copyObject } from '../../utils/object.utils'; 16 | 17 | export class WebGLRenderer extends Emitter implements IRenderer { 18 | // Contains the HTML5 Canvas element which is used for drawing nodes and edges. 19 | private readonly _context: WebGL2RenderingContext; 20 | 21 | width: number; 22 | height: number; 23 | private _settings: IRendererSettings; 24 | transform: ZoomTransform; 25 | 26 | constructor(context: WebGL2RenderingContext, settings?: Partial) { 27 | super(); 28 | this._context = context; 29 | console.log('context', this._context); 30 | 31 | this.width = DEFAULT_RENDERER_WIDTH; 32 | this.height = DEFAULT_RENDERER_HEIGHT; 33 | this.transform = zoomIdentity; 34 | this._settings = { 35 | ...DEFAULT_RENDERER_SETTINGS, 36 | ...settings, 37 | }; 38 | } 39 | 40 | get isInitiallyRendered(): boolean { 41 | throw new Error('Method not implemented.'); 42 | } 43 | 44 | getSettings(): IRendererSettings { 45 | return copyObject(this._settings); 46 | } 47 | 48 | setSettings(settings: Partial): void { 49 | this._settings = { 50 | ...this._settings, 51 | ...settings, 52 | }; 53 | } 54 | 55 | render(graph: IGraph): void { 56 | console.log('graph:', graph); 57 | throw new Error('Method not implemented.'); 58 | } 59 | 60 | reset(): void { 61 | throw new Error('Method not implemented.'); 62 | } 63 | 64 | getFitZoomTransform(graph: IGraph): ZoomTransform { 65 | console.log('graph:', graph); 66 | throw new Error('Method not implemented.'); 67 | } 68 | 69 | getSimulationPosition(canvasPoint: IPosition): IPosition { 70 | console.log('canvasPoint:', canvasPoint); 71 | throw new Error('Method not implemented.'); 72 | } 73 | 74 | getSimulationViewRectangle(): IRectangle { 75 | throw new Error('Method not implemented.'); 76 | } 77 | 78 | translateOriginToCenter(): void { 79 | throw new Error('Method not implemented.'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/renderer/shared.ts: -------------------------------------------------------------------------------- 1 | import { ZoomTransform } from 'd3-zoom'; 2 | import { Color, IPosition, IRectangle } from '../common'; 3 | import { INodeBase } from '../models/node'; 4 | import { IEdgeBase } from '../models/edge'; 5 | import { IGraph } from '../models/graph'; 6 | import { IEmitter } from '../utils/emitter.utils'; 7 | 8 | export enum RendererType { 9 | CANVAS = 'canvas', 10 | WEBGL = 'webgl', 11 | } 12 | 13 | export enum RenderEventType { 14 | RENDER_START = 'render-start', 15 | RENDER_END = 'render-end', 16 | } 17 | 18 | export interface IRendererSettings { 19 | fps: number; 20 | minZoom: number; 21 | maxZoom: number; 22 | fitZoomMargin: number; 23 | labelsIsEnabled: boolean; 24 | labelsOnEventIsEnabled: boolean; 25 | shadowIsEnabled: boolean; 26 | shadowOnEventIsEnabled: boolean; 27 | contextAlphaOnEvent: number; 28 | contextAlphaOnEventIsEnabled: boolean; 29 | backgroundColor: Color | string | null; 30 | } 31 | 32 | export interface IRendererSettingsInit extends IRendererSettings { 33 | type: RendererType; 34 | } 35 | 36 | export type RendererEvents = { 37 | [RenderEventType.RENDER_START]: undefined; 38 | [RenderEventType.RENDER_END]: { durationMs: number }; 39 | }; 40 | 41 | export interface IRenderer extends IEmitter { 42 | // Width and height of the canvas. Used for clearing. 43 | width: number; 44 | height: number; 45 | 46 | // Includes translation (pan) in the x and y direction 47 | // as well as scaling (level of zoom). 48 | transform: ZoomTransform; 49 | 50 | get isInitiallyRendered(): boolean; 51 | 52 | getSettings(): IRendererSettings; 53 | 54 | setSettings(settings: Partial): void; 55 | 56 | render(graph: IGraph): void; 57 | 58 | reset(): void; 59 | 60 | getFitZoomTransform(graph: IGraph): ZoomTransform; 61 | 62 | getSimulationPosition(canvasPoint: IPosition): IPosition; 63 | 64 | /** 65 | * Returns the visible rectangle view in the simulation coordinates. 66 | * 67 | * @return {IRectangle} Visible view in the simulation coordinates 68 | */ 69 | getSimulationViewRectangle(): IRectangle; 70 | 71 | translateOriginToCenter(): void; 72 | } 73 | 74 | export const DEFAULT_RENDERER_SETTINGS: IRendererSettings = { 75 | fps: 60, 76 | minZoom: 0.25, 77 | maxZoom: 8, 78 | fitZoomMargin: 0.2, 79 | labelsIsEnabled: true, 80 | labelsOnEventIsEnabled: true, 81 | shadowIsEnabled: true, 82 | shadowOnEventIsEnabled: true, 83 | contextAlphaOnEvent: 0.3, 84 | contextAlphaOnEventIsEnabled: true, 85 | backgroundColor: null, 86 | }; 87 | 88 | export const DEFAULT_RENDERER_WIDTH = 640; 89 | export const DEFAULT_RENDERER_HEIGHT = 480; 90 | -------------------------------------------------------------------------------- /src/utils/type.utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes all deep properties partial. Same as Partial but deep. 3 | */ 4 | export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T; 5 | 6 | /** 7 | * Makes all deep properties required. Same as Required but deep. 8 | */ 9 | export type DeepRequired = T extends object ? { [P in keyof T]-?: DeepRequired } : T; 10 | 11 | /** 12 | * Type check for string values. 13 | * 14 | * @param {any} value Any value 15 | * @return {boolean} True if it is a string, false otherwise 16 | */ 17 | export const isString = (value: any): value is string => { 18 | return typeof value === 'string'; 19 | }; 20 | 21 | /** 22 | * Type check for number values. 23 | * 24 | * @param {any} value Any value 25 | * @return {boolean} True if it is a number, false otherwise 26 | */ 27 | export const isNumber = (value: any): value is number => { 28 | return typeof value === 'number'; 29 | }; 30 | 31 | /** 32 | * Type check for boolean values. 33 | * 34 | * @param {any} value Any value 35 | * @return {boolean} True if it is a boolean, false otherwise 36 | */ 37 | export const isBoolean = (value: any): value is boolean => { 38 | return typeof value === 'boolean'; 39 | }; 40 | 41 | /** 42 | * Type check for Date values. 43 | * 44 | * @param {any} value Any value 45 | * @return {boolean} True if it is a Date, false otherwise 46 | */ 47 | export const isDate = (value: any): value is Date => { 48 | return value instanceof Date; 49 | }; 50 | 51 | /** 52 | * Type check for Array values. Alias for `Array.isArray`. 53 | * 54 | * @param {any} value Any value 55 | * @return {boolean} True if it is an Array, false otherwise 56 | */ 57 | export const isArray = (value: any): value is Array => { 58 | return Array.isArray(value); 59 | }; 60 | 61 | /** 62 | * Type check for plain object values: { [key]: value } 63 | * 64 | * @param {any} value Any value 65 | * @return {boolean} True if it is a plain object, false otherwise 66 | */ 67 | export const isPlainObject = (value: any): value is Record => { 68 | return value !== null && typeof value === 'object' && value.constructor.name === 'Object'; 69 | }; 70 | 71 | /** 72 | * Type check for null values. 73 | * 74 | * @param {any} value Any value 75 | * @return {boolean} True if it is a null, false otherwise 76 | */ 77 | export const isNull = (value: any): value is null => { 78 | return value === null; 79 | }; 80 | 81 | /** 82 | * Type check for Function values. 83 | * 84 | * @param {any} value Any value 85 | * @return {boolean} True if it is a Function, false otherwise 86 | */ 87 | export const isFunction = (value: any): value is Function => { 88 | return typeof value === 'function'; 89 | }; 90 | -------------------------------------------------------------------------------- /examples/example-fixed-coordinates-graph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Simple graph with fixed coordinates 7 | 8 | 9 | 15 | 16 |
    17 |

    Example 3 - Fixed coordinates

    18 |

    19 | Renders a simple graph with fixed node coordinates where Orb won't simulate and position the nodes. 20 |

    21 | 22 | 26 |
    27 |
    28 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/models/topology.ts: -------------------------------------------------------------------------------- 1 | import { INodeBase } from './node'; 2 | import { IEdge, IEdgeBase } from './edge'; 3 | 4 | export const getEdgeOffsets = (edges: IEdge[]): number[] => { 5 | const edgeOffsets = new Array(edges.length); 6 | const edgeOffsetsByUniqueKey = getEdgeOffsetsByUniqueKey(edges); 7 | 8 | for (let i = 0; i < edges.length; i++) { 9 | const edge = edges[i]; 10 | let offset = 0; 11 | 12 | const uniqueKey = getUniqueEdgeKey(edge); 13 | const edgeOffsetsByKey = edgeOffsetsByUniqueKey[uniqueKey]; 14 | if (edgeOffsetsByKey && edgeOffsetsByKey.length) { 15 | // Pull the first offset 16 | offset = edgeOffsetsByKey.shift() ?? 0; 17 | 18 | const isEdgeReverseDirection = edge.end < edge.start; 19 | if (isEdgeReverseDirection) { 20 | offset = -1 * offset; 21 | } 22 | } 23 | 24 | edgeOffsets[i] = offset; 25 | } 26 | 27 | return edgeOffsets; 28 | }; 29 | 30 | const getUniqueEdgeKey = (edge: E): string => { 31 | const sid = edge.start; 32 | const tid = edge.end; 33 | return sid < tid ? `${sid}-${tid}` : `${tid}-${sid}`; 34 | }; 35 | 36 | const getEdgeOffsetsByUniqueKey = ( 37 | edges: IEdge[], 38 | ): Record => { 39 | const edgeCountByUniqueKey: Record = {}; 40 | const loopbackUniqueKeys: Set = new Set(); 41 | 42 | // Count the number of edges that are between the same nodes 43 | for (let i = 0; i < edges.length; i++) { 44 | const uniqueKey = getUniqueEdgeKey(edges[i]); 45 | if (edges[i].start === edges[i].end) { 46 | loopbackUniqueKeys.add(uniqueKey); 47 | } 48 | edgeCountByUniqueKey[uniqueKey] = (edgeCountByUniqueKey[uniqueKey] ?? 0) + 1; 49 | } 50 | 51 | const edgeOffsetsByUniqueKey: Record = {}; 52 | const uniqueKeys = Object.keys(edgeCountByUniqueKey); 53 | 54 | for (let i = 0; i < uniqueKeys.length; i++) { 55 | const uniqueKey = uniqueKeys[i]; 56 | const edgeCount = edgeCountByUniqueKey[uniqueKey]; 57 | 58 | // Loopback offsets should be 1, 2, 3, ... 59 | if (loopbackUniqueKeys.has(uniqueKey)) { 60 | edgeOffsetsByUniqueKey[uniqueKey] = Array.from({ length: edgeCount }, (_, i) => i + 1); 61 | continue; 62 | } 63 | 64 | if (edgeCount <= 1) { 65 | continue; 66 | } 67 | 68 | const edgeOffsets: number[] = []; 69 | 70 | // 0 means straight line. There will be a straight line between two nodes 71 | // when there are 1 edge, 3 edges, 5 edges, ... 72 | if (edgeCount % 2 !== 0) { 73 | edgeOffsets.push(0); 74 | } 75 | 76 | for (let i = 2; i <= edgeCount; i += 2) { 77 | edgeOffsets.push(i / 2); 78 | edgeOffsets.push((i / 2) * -1); 79 | } 80 | 81 | edgeOffsetsByUniqueKey[uniqueKey] = edgeOffsets; 82 | } 83 | 84 | return edgeOffsetsByUniqueKey; 85 | }; 86 | -------------------------------------------------------------------------------- /examples/example-graph-on-map.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Graph on the map 7 | 8 | 9 | 10 | 16 | 17 |
    18 |

    Example 6 - Map

    19 |

    20 | Renders a graph with map background. Each node needs to provide latitude and 21 | longitude values. 22 |

    23 | 24 | 28 |
    29 |
    30 | 31 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/simulator/shared.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from '../common'; 2 | import { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'; 3 | import { ID3SimulatorEngineSettings, ID3SimulatorEngineSettingsUpdate } from './engine/d3-simulator-engine'; 4 | import { IEmitter } from '../utils/emitter.utils'; 5 | 6 | export type ISimulationNode = SimulationNodeDatum & { id: number; mass?: number }; 7 | export type ISimulationEdge = SimulationLinkDatum & { id: number }; 8 | 9 | export enum SimulatorEventType { 10 | SIMULATION_START = 'simulation-start', 11 | SIMULATION_PROGRESS = 'simulation-progress', 12 | SIMULATION_END = 'simulation-end', 13 | NODE_DRAG = 'node-drag', 14 | NODE_DRAG_END = 'node-drag-end', 15 | SETTINGS_UPDATE = 'settings-update', 16 | } 17 | 18 | export type SimulatorEvents = { 19 | [SimulatorEventType.SIMULATION_START]: undefined; 20 | [SimulatorEventType.SIMULATION_PROGRESS]: ISimulatorEventGraph & ISimulatorEventProgress; 21 | [SimulatorEventType.SIMULATION_END]: ISimulatorEventGraph; 22 | [SimulatorEventType.NODE_DRAG]: ISimulatorEventGraph; 23 | [SimulatorEventType.NODE_DRAG_END]: ISimulatorEventGraph; 24 | [SimulatorEventType.SETTINGS_UPDATE]: ISimulatorEventSettings; 25 | }; 26 | 27 | export interface ISimulator extends IEmitter { 28 | // Sets nodes and edges without running simulation 29 | setData(nodes: ISimulationNode[], edges: ISimulationEdge[]): void; 30 | addData(nodes: ISimulationNode[], edges: ISimulationEdge[]): void; 31 | updateData(nodes: ISimulationNode[], edges: ISimulationEdge[]): void; 32 | clearData(): void; 33 | 34 | // Simulation handlers 35 | simulate(): void; 36 | activateSimulation(): void; 37 | startSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]): void; 38 | updateSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]): void; 39 | stopSimulation(): void; 40 | 41 | // Node handlers 42 | startDragNode(): void; 43 | dragNode(nodeId: number, position: IPosition): void; 44 | endDragNode(nodeId: number): void; 45 | fixNodes(nodes?: ISimulationNode[]): void; 46 | releaseNodes(nodes?: ISimulationNode[]): void; 47 | 48 | // Settings handlers 49 | setSettings(settings: ID3SimulatorEngineSettingsUpdate): void; 50 | 51 | terminate(): void; 52 | } 53 | 54 | export interface ISimulatorEventGraph { 55 | nodes: ISimulationNode[]; 56 | edges: ISimulationEdge[]; 57 | } 58 | 59 | export interface ISimulatorEventProgress { 60 | progress: number; 61 | } 62 | 63 | export interface ISimulatorEventSettings { 64 | settings: ID3SimulatorEngineSettings; 65 | } 66 | 67 | export interface ISimulatorEvents { 68 | onNodeDrag: (data: ISimulatorEventGraph) => void; 69 | onNodeDragEnd: (data: ISimulatorEventGraph) => void; 70 | onSimulationStart: () => void; 71 | onSimulationProgress: (data: ISimulatorEventGraph & ISimulatorEventProgress) => void; 72 | onSimulationEnd: (data: ISimulatorEventGraph) => void; 73 | onSettingsUpdate: (data: ISimulatorEventSettings) => void; 74 | } 75 | -------------------------------------------------------------------------------- /src/services/images.ts: -------------------------------------------------------------------------------- 1 | export type ImageLoadedCallback = (error?: Error) => void; 2 | 3 | export class ImageHandler { 4 | private static _instance: ImageHandler; 5 | private readonly _imageByUrl: Record = {}; 6 | 7 | private constructor() { 8 | // Forbids usage of `new ImageHandler` - use `.getInstance()` instead 9 | } 10 | 11 | static getInstance(): ImageHandler { 12 | if (!ImageHandler._instance) { 13 | ImageHandler._instance = new ImageHandler(); 14 | } 15 | 16 | return ImageHandler._instance; 17 | } 18 | 19 | getImage(url: string): HTMLImageElement | undefined { 20 | return this._imageByUrl[url]; 21 | } 22 | 23 | loadImage(url: string, callback?: ImageLoadedCallback): HTMLImageElement { 24 | const existingImage = this.getImage(url); 25 | if (existingImage) { 26 | return existingImage; 27 | } 28 | 29 | const image = new Image(); 30 | this._imageByUrl[url] = image; 31 | 32 | image.onload = () => { 33 | fixImageSize(image); 34 | callback?.(); 35 | }; 36 | image.onerror = () => { 37 | callback?.(new Error(`Image ${url} failed to load.`)); 38 | }; 39 | image.src = url; 40 | 41 | return image; 42 | } 43 | 44 | loadImages(urls: string[], callback?: ImageLoadedCallback): HTMLImageElement[] { 45 | const images: HTMLImageElement[] = []; 46 | const pendingImageUrls: Set = new Set(urls); 47 | 48 | const onImageLoaded = (url: string) => { 49 | pendingImageUrls.delete(url); 50 | if (pendingImageUrls.size === 0) { 51 | callback?.(); 52 | } 53 | }; 54 | 55 | for (let i = 0; i < urls.length; i++) { 56 | const url = urls[i]; 57 | const existingImage = this._imageByUrl[url]; 58 | 59 | if (existingImage) { 60 | pendingImageUrls.delete(url); 61 | images.push(existingImage); 62 | continue; 63 | } 64 | 65 | const image = new Image(); 66 | this._imageByUrl[url] = image; 67 | 68 | image.onload = () => { 69 | fixImageSize(image); 70 | onImageLoaded(url); 71 | }; 72 | image.onerror = () => { 73 | onImageLoaded(url); 74 | }; 75 | 76 | image.src = url; 77 | 78 | images.push(image); 79 | } 80 | 81 | return images; 82 | } 83 | } 84 | 85 | /** 86 | * Credits to https://github.com/almende/vis/blob/master/lib/network/Images.js#L98:L111 87 | * Fixes the width and height on IE11. 88 | * 89 | * @param {HTMLImageElement} image Image object 90 | * @return {HTMLImageElement} Fixed image object 91 | */ 92 | const fixImageSize = (image: HTMLImageElement): HTMLImageElement => { 93 | if (!image || image.width !== 0) { 94 | return image; 95 | } 96 | 97 | document.body.appendChild(image); 98 | image.width = image.offsetWidth; 99 | image.height = image.offsetHeight; 100 | document.body.removeChild(image); 101 | 102 | return image; 103 | }; 104 | -------------------------------------------------------------------------------- /examples/example-custom-styled-graph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Simple graph with custom default style on default canvas 7 | 8 | 9 | 15 | 16 |
    17 |

    Example 2 - Basic + Custom default style

    18 |

    19 | Renders a simple graph with a custom default style (color, size, border, text) for nodes and edges. 20 |

    21 | 22 | 26 |
    27 |
    28 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/types/edge-straight.ts: -------------------------------------------------------------------------------- 1 | import { INodeBase, INode } from '../../../../models/node'; 2 | import { EdgeStraight, IEdgeBase } from '../../../../models/edge'; 3 | import { IBorderPosition, IEdgeArrow } from '../shared'; 4 | 5 | export const drawStraightLine = ( 6 | context: CanvasRenderingContext2D, 7 | edge: EdgeStraight, 8 | ) => { 9 | // Default line is the straight line 10 | const sourcePoint = edge.startNode.getCenter(); 11 | const targetPoint = edge.endNode.getCenter(); 12 | // TODO @toni: make getCenter to return undefined?! 13 | if (!sourcePoint || !targetPoint) { 14 | return; 15 | } 16 | 17 | context.beginPath(); 18 | context.moveTo(sourcePoint.x, sourcePoint.y); 19 | context.lineTo(targetPoint.x, targetPoint.y); 20 | context.stroke(); 21 | }; 22 | 23 | /** 24 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/Edge.js} 25 | * 26 | * @param {EdgeStraight} edge Edge 27 | * @return {IEdgeArrow} Arrow shape 28 | */ 29 | export const getStraightArrowShape = ( 30 | edge: EdgeStraight, 31 | ): IEdgeArrow => { 32 | const scaleFactor = edge.style.arrowSize ?? 1; 33 | const lineWidth = edge.getWidth() ?? 1; 34 | const sourcePoint = edge.startNode.getCenter(); 35 | const targetPoint = edge.endNode.getCenter(); 36 | 37 | const angle = Math.atan2(targetPoint.y - sourcePoint.y, targetPoint.x - sourcePoint.x); 38 | const arrowPoint = findBorderPoint(edge, edge.endNode); 39 | 40 | const length = 1.5 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. 41 | 42 | const xi = arrowPoint.x - length * 0.9 * Math.cos(angle); 43 | const yi = arrowPoint.y - length * 0.9 * Math.sin(angle); 44 | const arrowCore = { x: xi, y: yi }; 45 | 46 | return { point: arrowPoint, core: arrowCore, angle, length }; 47 | }; 48 | 49 | /** 50 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/edges/straight-edge.ts} 51 | * 52 | * @param {EdgeStraight} edge Edge 53 | * @param {INode} nearNode Node close to the edge 54 | * @return {IBorderPosition} Position on the border of the node 55 | */ 56 | const findBorderPoint = ( 57 | edge: EdgeStraight, 58 | nearNode: INode, 59 | ): IBorderPosition => { 60 | let endNode = edge.endNode; 61 | let startNode = edge.startNode; 62 | if (nearNode.id === edge.startNode.id) { 63 | endNode = edge.startNode; 64 | startNode = edge.endNode; 65 | } 66 | 67 | const endNodePoints = endNode.getCenter(); 68 | const startNodePoints = startNode.getCenter(); 69 | 70 | // const angle = Math.atan2(endNodePoints.y - startNodePoints.y, endNodePoints.x - startNodePoints.x); 71 | const dx = endNodePoints.x - startNodePoints.x; 72 | const dy = endNodePoints.y - startNodePoints.y; 73 | const edgeSegmentLength = Math.sqrt(dx * dx + dy * dy); 74 | // const toBorderDist = nearNode.getDistanceToBorder(angle); 75 | const toBorderDist = nearNode.getDistanceToBorder(); 76 | const toBorderPoint = (edgeSegmentLength - toBorderDist) / edgeSegmentLength; 77 | 78 | return { 79 | x: (1 - toBorderPoint) * startNodePoints.x + toBorderPoint * endNodePoints.x, 80 | y: (1 - toBorderPoint) * startNodePoints.y + toBorderPoint * endNodePoints.y, 81 | t: 0, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@memgraph/orb", 3 | "version": "0.4.3", 4 | "description": "Graph visualization library", 5 | "engines": { 6 | "node": ">=16.0.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/memgraph/orb.git" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "contributors": [ 15 | { 16 | "name": "David Lozic", 17 | "url": "https://github.com/cizl" 18 | }, 19 | { 20 | "name": "Toni Lastre", 21 | "url": "https://github.com/tonilastre" 22 | } 23 | ], 24 | "author": "Memgraph Ltd. ", 25 | "license": "Apache-2.0", 26 | "scripts": { 27 | "build": "tsc", 28 | "build:release": "tsc && webpack", 29 | "webpack": "webpack", 30 | "webpack:watch": "webpack --watch", 31 | "serve": "http-server ./dist/browser/", 32 | "test": "jest --runInBand --detectOpenHandles --forceExit --verbose --useStderr", 33 | "coverage": "npm test -- --coverage --collectCoverageFrom='./src/**'", 34 | "lint": "eslint .", 35 | "lint:fix": "eslint --fix .", 36 | "release": "semantic-release --branches main", 37 | "prepare": "husky install" 38 | }, 39 | "dependencies": { 40 | "d3-drag": "3.0.0", 41 | "d3-ease": "3.0.1", 42 | "d3-force": "3.0.0", 43 | "d3-selection": "3.0.0", 44 | "d3-transition": "3.0.1", 45 | "d3-zoom": "3.0.0", 46 | "leaflet": "1.8.0" 47 | }, 48 | "files": [ 49 | "dist", 50 | "LICENSE", 51 | "README.md" 52 | ], 53 | "publishConfig": { 54 | "registry": "https://registry.npmjs.org/" 55 | }, 56 | "devDependencies": { 57 | "@commitlint/cli": "17.0.0", 58 | "@commitlint/config-conventional": "17.0.0", 59 | "@semantic-release/changelog": "6.0.1", 60 | "@semantic-release/git": "10.0.1", 61 | "@types/d3-drag": "3.0.1", 62 | "@types/d3-ease": "3.0.0", 63 | "@types/d3-force": "3.0.3", 64 | "@types/d3-selection": "3.0.1", 65 | "@types/d3-transition": "3.0.1", 66 | "@types/d3-zoom": "3.0.1", 67 | "@types/jest": "27.4.1", 68 | "@types/leaflet": "1.7.9", 69 | "@types/resize-observer-browser": "^0.1.7", 70 | "@typescript-eslint/eslint-plugin": "5.24.0", 71 | "@typescript-eslint/parser": "5.24.0", 72 | "conventional-changelog-eslint": "3.0.9", 73 | "copy-webpack-plugin": "^11.0.0", 74 | "eslint": "8.15.0", 75 | "eslint-config-google": "0.14.0", 76 | "eslint-config-prettier": "8.5.0", 77 | "eslint-plugin-jest": "26.2.2", 78 | "eslint-plugin-prettier": "4.0.0", 79 | "http-server": "^14.1.1", 80 | "husky": "^8.0.1", 81 | "jest": "27.5.1", 82 | "prettier": "^2.7.1", 83 | "semantic-release": "19.0.3", 84 | "ts-jest": "27.1.4", 85 | "ts-loader": "^9.3.1", 86 | "ts-node": "10.8.0", 87 | "typescript": "4.6.3", 88 | "webpack": "^5.73.0", 89 | "webpack-cli": "^4.10.0", 90 | "webpack-dev-server": "^4.9.3" 91 | }, 92 | "jest": { 93 | "moduleFileExtensions": [ 94 | "js", 95 | "json", 96 | "ts" 97 | ], 98 | "rootDir": "", 99 | "testRegex": ".spec.ts$", 100 | "transform": { 101 | "^.+\\.(t|j)s$": "ts-jest" 102 | }, 103 | "coverageDirectory": "coverage", 104 | "testEnvironment": "node", 105 | "testTimeout": 5000 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/utils/object.utils.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isDate, isPlainObject } from './type.utils'; 2 | 3 | /** 4 | * Creates a new deep copy of the received object. Dates, arrays and 5 | * plain objects will be created as new objects (new reference). 6 | * 7 | * @param {any} obj Object 8 | * @return {any} Deep copied object 9 | */ 10 | export const copyObject = (obj: T): T => { 11 | if (isDate(obj)) { 12 | return copyDate(obj) as T; 13 | } 14 | 15 | if (isArray(obj)) { 16 | return copyArray(obj) as T; 17 | } 18 | 19 | if (isPlainObject(obj)) { 20 | return copyPlainObject(obj) as T; 21 | } 22 | 23 | // It is a primitive, function or a custom class 24 | return obj; 25 | }; 26 | 27 | /** 28 | * Checks if two objects are equal by value. It does deep checking for 29 | * values within arrays or plain objects. Equality for anything that is 30 | * not a Date, Array, or a plain object will be checked as `a === b`. 31 | * 32 | * @param {any} obj1 Object 33 | * @param {any} obj2 Object 34 | * @return {boolean} True if objects are deeply equal, otherwise false 35 | */ 36 | export const isObjectEqual = (obj1: any, obj2: any): boolean => { 37 | const isDate1 = isDate(obj1); 38 | const isDate2 = isDate(obj2); 39 | 40 | if ((isDate1 && !isDate2) || (!isDate1 && isDate2)) { 41 | return false; 42 | } 43 | 44 | if (isDate1 && isDate2) { 45 | return obj1.getTime() === obj2.getTime(); 46 | } 47 | 48 | const isArray1 = isArray(obj1); 49 | const isArray2 = isArray(obj2); 50 | 51 | if ((isArray1 && !isArray2) || (!isArray1 && isArray2)) { 52 | return false; 53 | } 54 | 55 | if (isArray1 && isArray2) { 56 | if (obj1.length !== obj2.length) { 57 | return false; 58 | } 59 | 60 | return obj1.every((value: any, index: number) => { 61 | return isObjectEqual(value, obj2[index]); 62 | }); 63 | } 64 | 65 | const isObject1 = isPlainObject(obj1); 66 | const isObject2 = isPlainObject(obj2); 67 | 68 | if ((isObject1 && !isObject2) || (!isObject1 && isObject2)) { 69 | return false; 70 | } 71 | 72 | if (isObject1 && isObject2) { 73 | const keys1 = Object.keys(obj1); 74 | const keys2 = Object.keys(obj2); 75 | 76 | if (!isObjectEqual(keys1, keys2)) { 77 | return false; 78 | } 79 | 80 | return keys1.every((key) => { 81 | return isObjectEqual(obj1[key], obj2[key]); 82 | }); 83 | } 84 | 85 | return obj1 === obj2; 86 | }; 87 | 88 | /** 89 | * Copies date object into a new date object. 90 | * 91 | * @param {Date} date Date 92 | * @return {Date} Date object copy 93 | */ 94 | const copyDate = (date: Date): Date => { 95 | return new Date(date); 96 | }; 97 | 98 | /** 99 | * Deep copies an array into a new array. Array values will 100 | * be deep copied too. 101 | * 102 | * @param {Array} array Array 103 | * @return {Array} Deep copied array 104 | */ 105 | const copyArray = (array: T[]): T[] => { 106 | return array.map((value) => copyObject(value)); 107 | }; 108 | 109 | /** 110 | * Deep copies a plain object into a new plain object. Object 111 | * values will be deep copied too. 112 | * 113 | * @param {Record} obj Object 114 | * @return {Record} Deep copied object 115 | */ 116 | const copyPlainObject = (obj: Record): Record => { 117 | const newObject: Record = {}; 118 | Object.keys(obj).forEach((key) => { 119 | newObject[key] = copyObject(obj[key]); 120 | }); 121 | return newObject; 122 | }; 123 | -------------------------------------------------------------------------------- /examples/playground.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Playground 7 | 8 | 9 | 10 | 16 | 17 |
    18 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /src/simulator/types/main-thread-simulator.ts: -------------------------------------------------------------------------------- 1 | import { ISimulationEdge, ISimulationNode, ISimulator, SimulatorEvents, SimulatorEventType } from '../shared'; 2 | import { IPosition } from '../../common'; 3 | import { Emitter } from '../../utils/emitter.utils'; 4 | import { 5 | D3SimulatorEngine, 6 | D3SimulatorEngineEventType, 7 | ID3SimulatorEngineSettingsUpdate, 8 | } from '../engine/d3-simulator-engine'; 9 | 10 | export class MainThreadSimulator extends Emitter implements ISimulator { 11 | protected readonly simulator: D3SimulatorEngine; 12 | 13 | constructor() { 14 | super(); 15 | this.simulator = new D3SimulatorEngine(); 16 | this.simulator.on(D3SimulatorEngineEventType.SIMULATION_START, () => { 17 | this.emit(SimulatorEventType.SIMULATION_START, undefined); 18 | }); 19 | this.simulator.on(D3SimulatorEngineEventType.SIMULATION_PROGRESS, (data) => { 20 | this.emit(SimulatorEventType.SIMULATION_PROGRESS, data); 21 | }); 22 | this.simulator.on(D3SimulatorEngineEventType.SIMULATION_END, (data) => { 23 | this.emit(SimulatorEventType.SIMULATION_END, data); 24 | }); 25 | this.simulator.on(D3SimulatorEngineEventType.NODE_DRAG, (data) => { 26 | this.emit(SimulatorEventType.NODE_DRAG_END, data); 27 | }); 28 | this.simulator.on(D3SimulatorEngineEventType.TICK, (data) => { 29 | this.emit(SimulatorEventType.NODE_DRAG, data); 30 | }); 31 | this.simulator.on(D3SimulatorEngineEventType.END, (data) => { 32 | this.emit(SimulatorEventType.NODE_DRAG_END, data); 33 | }); 34 | this.simulator.on(D3SimulatorEngineEventType.SETTINGS_UPDATE, (data) => { 35 | this.emit(SimulatorEventType.SETTINGS_UPDATE, data); 36 | }); 37 | } 38 | 39 | setData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 40 | this.simulator.setData({ nodes, edges }); 41 | } 42 | 43 | addData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 44 | this.simulator.addData({ nodes, edges }); 45 | } 46 | 47 | updateData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 48 | this.simulator.updateData({ nodes, edges }); 49 | } 50 | 51 | clearData() { 52 | this.simulator.clearData(); 53 | } 54 | 55 | simulate() { 56 | this.simulator.simulate(); 57 | } 58 | 59 | activateSimulation() { 60 | this.simulator.activateSimulation(); 61 | } 62 | 63 | startSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 64 | this.simulator.startSimulation({ nodes, edges }); 65 | } 66 | 67 | updateSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 68 | this.simulator.updateSimulation({ nodes, edges }); 69 | } 70 | 71 | stopSimulation() { 72 | this.simulator.stopSimulation(); 73 | } 74 | 75 | startDragNode() { 76 | this.simulator.startDragNode(); 77 | } 78 | 79 | dragNode(nodeId: number, position: IPosition) { 80 | this.simulator.dragNode({ id: nodeId, ...position }); 81 | } 82 | 83 | endDragNode(nodeId: number) { 84 | this.simulator.endDragNode({ id: nodeId }); 85 | } 86 | 87 | fixNodes(nodes: ISimulationNode[]) { 88 | this.simulator.fixNodes(nodes); 89 | } 90 | 91 | releaseNodes(nodes?: ISimulationNode[] | undefined): void { 92 | this.simulator.releaseNodes(nodes); 93 | } 94 | 95 | setSettings(settings: ID3SimulatorEngineSettingsUpdate) { 96 | this.simulator.setSettings(settings); 97 | } 98 | 99 | terminate() { 100 | this.simulator.removeAllListeners(); 101 | this.removeAllListeners(); 102 | // Do nothing 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/process.worker.ts: -------------------------------------------------------------------------------- 1 | // / 2 | import { D3SimulatorEngine, D3SimulatorEngineEventType } from '../../engine/d3-simulator-engine'; 3 | import { IWorkerInputPayload, WorkerInputType } from './message/worker-input'; 4 | import { IWorkerOutputPayload, WorkerOutputType } from './message/worker-output'; 5 | 6 | const simulator = new D3SimulatorEngine(); 7 | 8 | const emitToMain = (message: IWorkerOutputPayload) => { 9 | // @ts-ignore Web worker postMessage is a global function 10 | postMessage(message); 11 | }; 12 | 13 | simulator.on(D3SimulatorEngineEventType.TICK, (data) => { 14 | emitToMain({ type: WorkerOutputType.NODE_DRAG, data }); 15 | }); 16 | 17 | simulator.on(D3SimulatorEngineEventType.END, (data) => { 18 | emitToMain({ type: WorkerOutputType.NODE_DRAG_END, data }); 19 | }); 20 | 21 | simulator.on(D3SimulatorEngineEventType.SIMULATION_START, () => { 22 | emitToMain({ type: WorkerOutputType.SIMULATION_START }); 23 | }); 24 | 25 | simulator.on(D3SimulatorEngineEventType.SIMULATION_PROGRESS, (data) => { 26 | emitToMain({ type: WorkerOutputType.SIMULATION_PROGRESS, data }); 27 | }); 28 | 29 | simulator.on(D3SimulatorEngineEventType.SIMULATION_END, (data) => { 30 | emitToMain({ type: WorkerOutputType.SIMULATION_END, data }); 31 | }); 32 | 33 | simulator.on(D3SimulatorEngineEventType.NODE_DRAG, (data) => { 34 | // Notify the client that the node position changed. 35 | // This is otherwise handled by the simulation tick if physics is enabled. 36 | emitToMain({ type: WorkerOutputType.NODE_DRAG, data }); 37 | }); 38 | 39 | simulator.on(D3SimulatorEngineEventType.SETTINGS_UPDATE, (data) => { 40 | emitToMain({ type: WorkerOutputType.SETTINGS_UPDATE, data }); 41 | }); 42 | 43 | addEventListener('message', ({ data }: MessageEvent) => { 44 | switch (data.type) { 45 | case WorkerInputType.ActivateSimulation: { 46 | simulator.activateSimulation(); 47 | break; 48 | } 49 | 50 | case WorkerInputType.SetData: { 51 | simulator.setData(data.data); 52 | break; 53 | } 54 | 55 | case WorkerInputType.AddData: { 56 | simulator.addData(data.data); 57 | break; 58 | } 59 | 60 | case WorkerInputType.UpdateData: { 61 | simulator.updateData(data.data); 62 | break; 63 | } 64 | 65 | case WorkerInputType.ClearData: { 66 | simulator.clearData(); 67 | break; 68 | } 69 | 70 | case WorkerInputType.Simulate: { 71 | simulator.simulate(); 72 | break; 73 | } 74 | 75 | case WorkerInputType.StartSimulation: { 76 | simulator.startSimulation(data.data); 77 | break; 78 | } 79 | 80 | case WorkerInputType.UpdateSimulation: { 81 | simulator.updateSimulation(data.data); 82 | break; 83 | } 84 | 85 | case WorkerInputType.StopSimulation: { 86 | simulator.stopSimulation(); 87 | break; 88 | } 89 | 90 | case WorkerInputType.StartDragNode: { 91 | simulator.startDragNode(); 92 | break; 93 | } 94 | 95 | case WorkerInputType.DragNode: { 96 | simulator.dragNode(data.data); 97 | break; 98 | } 99 | 100 | case WorkerInputType.FixNodes: { 101 | simulator.fixNodes(data.data.nodes); 102 | break; 103 | } 104 | 105 | case WorkerInputType.ReleaseNodes: { 106 | simulator.releaseNodes(data.data.nodes); 107 | break; 108 | } 109 | 110 | case WorkerInputType.EndDragNode: { 111 | simulator.endDragNode(data.data); 112 | break; 113 | } 114 | 115 | case WorkerInputType.SetSettings: { 116 | simulator.setSettings(data.data); 117 | break; 118 | } 119 | } 120 | }); 121 | -------------------------------------------------------------------------------- /examples/example-graph-data-changes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Graph dynamics: Add, update, and remove nodes and edges 7 | 8 | 9 | 15 | 16 |
    17 |

    Example 5 - Dynamics

    18 |

    19 | Renders a simple graph to show graph dynamics: adding, updating, and removing 20 | nodes and edges. In intervals of 3 seconds, 1 new node and 1 new edge will be 21 | added to the graph. Node will be removed from the graph on node click event. 22 |

    23 | 24 | 28 |
    29 |
    30 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/message/worker-input.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from '../../../../common'; 2 | import { ISimulationNode, ISimulationEdge } from '../../../shared'; 3 | import { ID3SimulatorEngineSettingsUpdate } from '../../../engine/d3-simulator-engine'; 4 | import { IWorkerPayload } from './worker-payload'; 5 | 6 | // Messages are objects going into the simulation worker. 7 | // They can be thought of similar to requests. 8 | // (not quite as there is no immediate response to a request) 9 | 10 | export enum WorkerInputType { 11 | // Set node and edge data without simulating 12 | SetData = 'Set Data', 13 | AddData = 'Add Data', 14 | UpdateData = 'Update Data', 15 | ClearData = 'Clear Data', 16 | 17 | // Simulation message types 18 | Simulate = 'Simulate', 19 | ActivateSimulation = 'Activate Simulation', 20 | StartSimulation = 'Start Simulation', 21 | UpdateSimulation = 'Update Simulation', 22 | StopSimulation = 'Stop Simulation', 23 | 24 | // Node dragging message types 25 | StartDragNode = 'Start Drag Node', 26 | DragNode = 'Drag Node', 27 | EndDragNode = 'End Drag Node', 28 | FixNodes = 'Fix Nodes', 29 | ReleaseNodes = 'Release Nodes', 30 | 31 | // Settings and special params 32 | SetSettings = 'Set Settings', 33 | } 34 | 35 | type IWorkerInputSetDataPayload = IWorkerPayload< 36 | WorkerInputType.SetData, 37 | { 38 | nodes: ISimulationNode[]; 39 | edges: ISimulationEdge[]; 40 | } 41 | >; 42 | 43 | type IWorkerInputAddDataPayload = IWorkerPayload< 44 | WorkerInputType.AddData, 45 | { 46 | nodes: ISimulationNode[]; 47 | edges: ISimulationEdge[]; 48 | } 49 | >; 50 | 51 | type IWorkerInputUpdateDataPayload = IWorkerPayload< 52 | WorkerInputType.UpdateData, 53 | { 54 | nodes: ISimulationNode[]; 55 | edges: ISimulationEdge[]; 56 | } 57 | >; 58 | 59 | type IWorkerInputClearDataPayload = IWorkerPayload; 60 | 61 | type IWorkerInputSimulatePayload = IWorkerPayload; 62 | 63 | type IWorkerInputActivateSimulationPayload = IWorkerPayload; 64 | 65 | type IWorkerInputStartSimulationPayload = IWorkerPayload< 66 | WorkerInputType.StartSimulation, 67 | { 68 | nodes: ISimulationNode[]; 69 | edges: ISimulationEdge[]; 70 | } 71 | >; 72 | 73 | type IWorkerInputUpdateSimulationPayload = IWorkerPayload< 74 | WorkerInputType.UpdateSimulation, 75 | { 76 | nodes: ISimulationNode[]; 77 | edges: ISimulationEdge[]; 78 | } 79 | >; 80 | 81 | type IWorkerInputStopSimulationPayload = IWorkerPayload; 82 | 83 | type IWorkerInputStartDragNodePayload = IWorkerPayload; 84 | 85 | type IWorkerInputDragNodePayload = IWorkerPayload; 86 | 87 | type IWorkerInputEndDragNodePayload = IWorkerPayload< 88 | WorkerInputType.EndDragNode, 89 | { 90 | id: number; 91 | } 92 | >; 93 | 94 | type IWorkerInputFixNodesPayload = IWorkerPayload< 95 | WorkerInputType.FixNodes, 96 | { 97 | nodes: ISimulationNode[] | undefined; 98 | } 99 | >; 100 | 101 | type IWorkerInputReleaseNodesPayload = IWorkerPayload< 102 | WorkerInputType.ReleaseNodes, 103 | { 104 | nodes: ISimulationNode[] | undefined; 105 | } 106 | >; 107 | 108 | type IWorkerInputSetSettingsPayload = IWorkerPayload; 109 | 110 | export type IWorkerInputPayload = 111 | | IWorkerInputSetDataPayload 112 | | IWorkerInputAddDataPayload 113 | | IWorkerInputUpdateDataPayload 114 | | IWorkerInputClearDataPayload 115 | | IWorkerInputSimulatePayload 116 | | IWorkerInputActivateSimulationPayload 117 | | IWorkerInputStartSimulationPayload 118 | | IWorkerInputUpdateSimulationPayload 119 | | IWorkerInputStopSimulationPayload 120 | | IWorkerInputStartDragNodePayload 121 | | IWorkerInputDragNodePayload 122 | | IWorkerInputFixNodesPayload 123 | | IWorkerInputReleaseNodesPayload 124 | | IWorkerInputEndDragNodePayload 125 | | IWorkerInputSetSettingsPayload; 126 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/types/edge-loopback.ts: -------------------------------------------------------------------------------- 1 | import { INode, INodeBase } from '../../../../models/node'; 2 | import { EdgeLoopback, IEdgeBase } from '../../../../models/edge'; 3 | import { IBorderPosition, IEdgeArrow } from '../shared'; 4 | import { ICircle, IPosition } from '../../../../common'; 5 | 6 | export const drawLoopbackLine = ( 7 | context: CanvasRenderingContext2D, 8 | edge: EdgeLoopback, 9 | ) => { 10 | // Draw line from a node to the same node! 11 | const { x, y, radius } = edge.getCircularData(); 12 | 13 | context.beginPath(); 14 | context.arc(x, y, radius, 0, 2 * Math.PI, false); 15 | context.closePath(); 16 | context.stroke(); 17 | }; 18 | 19 | /** 20 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/Edge.js} 21 | * 22 | * @param {EdgeLoopback} edge Edge 23 | * @return {IEdgeArrow} Arrow shape 24 | */ 25 | export const getLoopbackArrowShape = ( 26 | edge: EdgeLoopback, 27 | ): IEdgeArrow => { 28 | const scaleFactor = edge.style.arrowSize ?? 1; 29 | const lineWidth = edge.getWidth() ?? 1; 30 | const source = edge.startNode; 31 | // const target = this.data.target; 32 | 33 | const arrowPoint = findBorderPoint(edge, source); 34 | const angle = arrowPoint.t * -2 * Math.PI + 0.45 * Math.PI; 35 | 36 | const length = 1.5 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. 37 | 38 | const xi = arrowPoint.x - length * 0.9 * Math.cos(angle); 39 | const yi = arrowPoint.y - length * 0.9 * Math.sin(angle); 40 | const arrowCore = { x: xi, y: yi }; 41 | 42 | return { point: arrowPoint, core: arrowCore, angle, length }; 43 | }; 44 | 45 | /** 46 | * Get a point on a circle 47 | * @param {ICircle} circle 48 | * @param {number} percentage - Value between 0 (line start) and 1 (line end) 49 | * @return {IPosition} Position on the circle 50 | * @private 51 | */ 52 | const pointOnCircle = (circle: ICircle, percentage: number): IPosition => { 53 | const angle = percentage * 2 * Math.PI; 54 | return { 55 | x: circle.x + circle.radius * Math.cos(angle), 56 | y: circle.y - circle.radius * Math.sin(angle), 57 | }; 58 | }; 59 | 60 | const findBorderPoint = ( 61 | edge: EdgeLoopback, 62 | nearNode: INode, 63 | ): IBorderPosition => { 64 | const circle = edge.getCircularData(); 65 | const options = { low: 0.6, high: 1.0, direction: 1 }; 66 | 67 | let low = options.low; 68 | let high = options.high; 69 | const direction = options.direction; 70 | 71 | const maxIterations = 10; 72 | let iteration = 0; 73 | let pos: IBorderPosition = { x: 0, y: 0, t: 0 }; 74 | // let angle; 75 | let distanceToBorder; 76 | let distanceToPoint; 77 | let difference; 78 | const threshold = 0.05; 79 | let middle = (low + high) * 0.5; 80 | 81 | const nearNodePoint = nearNode.getCenter(); 82 | 83 | while (low <= high && iteration < maxIterations) { 84 | middle = (low + high) * 0.5; 85 | 86 | pos = { ...pointOnCircle(circle, middle), t: 0 }; 87 | // angle = Math.atan2(nearNodePoint.y - pos.y, nearNodePoint.x - pos.x); 88 | // distanceToBorder = nearNode.getDistanceToBorder(angle); 89 | distanceToBorder = nearNode.getDistanceToBorder(); 90 | distanceToPoint = Math.sqrt(Math.pow(pos.x - nearNodePoint.x, 2) + Math.pow(pos.y - nearNodePoint.y, 2)); 91 | difference = distanceToBorder - distanceToPoint; 92 | if (Math.abs(difference) < threshold) { 93 | break; // found 94 | } 95 | // distance to nodes is larger than distance to border --> t needs to be bigger if we're looking at the to node. 96 | if (difference > 0) { 97 | if (direction > 0) { 98 | low = middle; 99 | } else { 100 | high = middle; 101 | } 102 | } else { 103 | if (direction > 0) { 104 | high = middle; 105 | } else { 106 | low = middle; 107 | } 108 | } 109 | iteration++; 110 | } 111 | pos.t = middle ?? 0; 112 | 113 | return pos; 114 | }; 115 | -------------------------------------------------------------------------------- /src/common/color.ts: -------------------------------------------------------------------------------- 1 | export interface IColorRGB { 2 | r: number; 3 | g: number; 4 | b: number; 5 | } 6 | 7 | const IS_VALID_HEX = /^#[a-fA-F0-9]{6}$/; 8 | const DEFAULT_HEX = '#000000'; 9 | 10 | /** 11 | * Color object (HEX, RGB). 12 | */ 13 | export class Color { 14 | public readonly hex: string; 15 | public readonly rgb: IColorRGB; 16 | 17 | constructor(hex: string) { 18 | this.hex = IS_VALID_HEX.test(hex ?? '') ? hex : DEFAULT_HEX; 19 | this.rgb = hexToRgb(hex); 20 | } 21 | 22 | /** 23 | * Returns HEX representation of the color. 24 | * 25 | * @return {string} HEX color code (#XXXXXX) 26 | */ 27 | toString(): string { 28 | return this.hex; 29 | } 30 | 31 | /** 32 | * Returns darker color by the input factor. Default factor 33 | * is 0.3. Factor should be between 0 (same color) and 1 (black color). 34 | * 35 | * @param {number} factor Factor for the darker color 36 | * @return {Color} Darker color 37 | */ 38 | getDarkerColor(factor = 0.3): Color { 39 | return Color.getColorFromRGB({ 40 | r: this.rgb.r - factor * this.rgb.r, 41 | g: this.rgb.g - factor * this.rgb.g, 42 | b: this.rgb.b - factor * this.rgb.b, 43 | }); 44 | } 45 | 46 | /** 47 | * Returns lighter color by the input factor. Default factor 48 | * is 0.3. Factor should be between 0 (same color) and 1 (white color). 49 | * 50 | * @param {number} factor Factor for the lighter color 51 | * @return {Color} Lighter color 52 | */ 53 | getLighterColor(factor = 0.3): Color { 54 | return Color.getColorFromRGB({ 55 | r: this.rgb.r + factor * (255 - this.rgb.r), 56 | g: this.rgb.g + factor * (255 - this.rgb.g), 57 | b: this.rgb.b + factor * (255 - this.rgb.b), 58 | }); 59 | } 60 | 61 | /** 62 | * Returns a new color by mixing the input color with self. 63 | * 64 | * @param {Color} color Color to mix with 65 | * @return {Color} Mixed color 66 | */ 67 | getMixedColor(color: Color): Color { 68 | return Color.getColorFromRGB({ 69 | r: (this.rgb.r + color.rgb.r) / 2, 70 | g: (this.rgb.g + color.rgb.g) / 2, 71 | b: (this.rgb.b + color.rgb.b) / 2, 72 | }); 73 | } 74 | 75 | /** 76 | * Checks if it is an equal color. 77 | * 78 | * @param {Color} color Another color 79 | * @return {boolean} True if equal colors, otherwise false 80 | */ 81 | isEqual(color: Color): boolean { 82 | return this.rgb.r === color.rgb.r && this.rgb.g === color.rgb.g && this.rgb.b === color.rgb.b; 83 | } 84 | 85 | /** 86 | * Returns a color from RGB values. 87 | * 88 | * @param {IColorRGB} rgb RGB values 89 | * @return {Color} Color 90 | */ 91 | static getColorFromRGB(rgb: IColorRGB): Color { 92 | const r = Math.round(Math.max(Math.min(rgb.r, 255), 0)); 93 | const g = Math.round(Math.max(Math.min(rgb.g, 255), 0)); 94 | const b = Math.round(Math.max(Math.min(rgb.b, 255), 0)); 95 | 96 | return new Color(rgbToHex({ r, g, b })); 97 | } 98 | 99 | /** 100 | * Returns a random color. 101 | * 102 | * @return {Color} Random color 103 | */ 104 | static getRandomColor(): Color { 105 | return Color.getColorFromRGB({ 106 | r: Math.round(255 * Math.random()), 107 | g: Math.round(255 * Math.random()), 108 | b: Math.round(255 * Math.random()), 109 | }); 110 | } 111 | } 112 | 113 | /** 114 | * Converts HEX color code to RGB. Doesn't validate the HEX. 115 | * 116 | * @param {string} hex HEX color code (#XXXXXX) 117 | * @return {IColorRGB} RGB color 118 | */ 119 | const hexToRgb = (hex: string): IColorRGB => { 120 | return { 121 | r: parseInt(hex.substring(1, 3), 16), 122 | g: parseInt(hex.substring(3, 5), 16), 123 | b: parseInt(hex.substring(5, 7), 16), 124 | }; 125 | }; 126 | 127 | /** 128 | * Converts RGB color to HEX color code. 129 | * 130 | * @param {IColorRGB} rgb RGB color 131 | * @return {string} HEX color code (#XXXXXX) 132 | */ 133 | const rgbToHex = (rgb: IColorRGB): string => { 134 | return '#' + ((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1); 135 | }; 136 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { INode, INodeBase } from './models/node'; 2 | import { IEdge, IEdgeBase } from './models/edge'; 3 | import { Emitter } from './utils/emitter.utils'; 4 | import { IPosition } from './common'; 5 | 6 | export enum OrbEventType { 7 | // Renderer events for drawing on canvas 8 | RENDER_START = 'render-start', 9 | RENDER_END = 'render-end', 10 | // Simulation (D3) events for setting up node positions 11 | SIMULATION_START = 'simulation-start', 12 | SIMULATION_STEP = 'simulation-step', 13 | SIMULATION_END = 'simulation-end', 14 | // Mouse events: click, hover, move 15 | NODE_CLICK = 'node-click', 16 | NODE_HOVER = 'node-hover', 17 | EDGE_CLICK = 'edge-click', 18 | EDGE_HOVER = 'edge-hover', 19 | MOUSE_CLICK = 'mouse-click', 20 | MOUSE_MOVE = 'mouse-move', 21 | // Zoom or pan (translate) change 22 | TRANSFORM = 'transform', 23 | // Mouse node drag events 24 | NODE_DRAG_START = 'node-drag-start', 25 | NODE_DRAG = 'node-drag', 26 | NODE_DRAG_END = 'node-drag-end', 27 | NODE_RIGHT_CLICK = 'node-right-click', 28 | EDGE_RIGHT_CLICK = 'edge-right-click', 29 | MOUSE_RIGHT_CLICK = 'mouse-right-click', 30 | // Double click events 31 | NODE_DOUBLE_CLICK = 'node-double-click', 32 | EDGE_DOUBLE_CLICK = 'edge-double-click', 33 | MOUSE_DOUBLE_CLICK = 'mouse-double-click', 34 | } 35 | 36 | export interface IOrbEventDuration { 37 | durationMs: number; 38 | } 39 | 40 | export interface IOrbEventProgress { 41 | progress: number; 42 | } 43 | 44 | export interface IOrbEventTransform { 45 | transform: { 46 | x: number; 47 | y: number; 48 | k: number; 49 | }; 50 | } 51 | 52 | interface IOrbEventMousePosition { 53 | localPoint: IPosition; 54 | globalPoint: IPosition; 55 | } 56 | 57 | export interface IOrbEventMouseClickEvent extends IOrbEventMousePosition { 58 | event: PointerEvent; 59 | } 60 | 61 | export interface IOrbEventMouseMoveEvent extends IOrbEventMousePosition { 62 | event: MouseEvent; 63 | } 64 | 65 | export interface IOrbEventMouseEvent extends IOrbEventMousePosition { 66 | subject?: INode | IEdge; 67 | } 68 | 69 | export interface IOrbEventMouseNodeEvent { 70 | node: INode; 71 | } 72 | 73 | export interface IOrbEventMouseEdgeEvent { 74 | edge: IEdge; 75 | } 76 | 77 | export class OrbEmitter extends Emitter<{ 78 | [OrbEventType.RENDER_START]: undefined; 79 | [OrbEventType.RENDER_END]: IOrbEventDuration; 80 | [OrbEventType.SIMULATION_START]: undefined; 81 | [OrbEventType.SIMULATION_STEP]: IOrbEventProgress; 82 | [OrbEventType.SIMULATION_END]: IOrbEventDuration; 83 | [OrbEventType.NODE_CLICK]: IOrbEventMouseNodeEvent & IOrbEventMouseClickEvent; 84 | [OrbEventType.NODE_HOVER]: IOrbEventMouseNodeEvent & IOrbEventMouseMoveEvent; 85 | [OrbEventType.EDGE_CLICK]: IOrbEventMouseEdgeEvent & IOrbEventMouseClickEvent; 86 | [OrbEventType.EDGE_HOVER]: IOrbEventMouseEdgeEvent & IOrbEventMouseMoveEvent; 87 | [OrbEventType.MOUSE_CLICK]: IOrbEventMouseEvent & IOrbEventMouseClickEvent; 88 | [OrbEventType.MOUSE_MOVE]: IOrbEventMouseEvent & IOrbEventMouseMoveEvent; 89 | [OrbEventType.TRANSFORM]: IOrbEventTransform; 90 | [OrbEventType.NODE_DRAG_START]: IOrbEventMouseNodeEvent & IOrbEventMouseMoveEvent; 91 | [OrbEventType.NODE_DRAG]: IOrbEventMouseNodeEvent & IOrbEventMouseMoveEvent; 92 | [OrbEventType.NODE_DRAG_END]: IOrbEventMouseNodeEvent & IOrbEventMouseMoveEvent; 93 | [OrbEventType.NODE_RIGHT_CLICK]: IOrbEventMouseNodeEvent & IOrbEventMouseClickEvent; 94 | [OrbEventType.EDGE_RIGHT_CLICK]: IOrbEventMouseEdgeEvent & IOrbEventMouseClickEvent; 95 | [OrbEventType.MOUSE_RIGHT_CLICK]: IOrbEventMouseEvent & IOrbEventMouseClickEvent; 96 | [OrbEventType.NODE_DOUBLE_CLICK]: IOrbEventMouseNodeEvent & IOrbEventMouseClickEvent; 97 | [OrbEventType.EDGE_DOUBLE_CLICK]: IOrbEventMouseEdgeEvent & IOrbEventMouseClickEvent; 98 | [OrbEventType.MOUSE_DOUBLE_CLICK]: IOrbEventMouseEvent & IOrbEventMouseClickEvent; 99 | }> {} 100 | -------------------------------------------------------------------------------- /src/utils/entity.utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A key-value model that keeps an order of the elements by 3 | * the input sort function. 4 | * 5 | * Inspired by NgRx/EntityState: https://github.com/ngrx/platform 6 | */ 7 | export interface IEntityState { 8 | getOne(id: K): V | undefined; 9 | getMany(ids: K[], options?: Partial>): V[]; 10 | getAll(options?: Partial>): V[]; 11 | setOne(entity: V): void; 12 | setMany(entities: V[]): void; 13 | removeMany(ids: K[]): void; 14 | removeAll(): void; 15 | sort(): void; 16 | size: number; 17 | } 18 | 19 | export interface IEntityDefinition { 20 | getId: (entity: V) => K; 21 | sortBy?: (entity1: V, entity2: V) => number; 22 | } 23 | 24 | export interface IEntityGetOptions { 25 | filterBy: (entity: V) => boolean; 26 | } 27 | 28 | export class EntityState implements IEntityState { 29 | private ids: K[] = []; 30 | private entityById: Map = new Map(); 31 | private getId: IEntityDefinition['getId']; 32 | private sortBy?: IEntityDefinition['sortBy']; 33 | 34 | constructor(definition: IEntityDefinition) { 35 | this.getId = definition.getId; 36 | this.sortBy = definition.sortBy; 37 | } 38 | 39 | getOne(id: K): V | undefined { 40 | return this.entityById.get(id); 41 | } 42 | 43 | getMany(ids: K[], options?: Partial>): V[] { 44 | const entities: V[] = []; 45 | for (let i = 0; i < ids.length; i++) { 46 | const entity = this.getOne(ids[i]); 47 | if (entity === undefined) { 48 | continue; 49 | } 50 | 51 | if (options?.filterBy && !options.filterBy(entity)) { 52 | continue; 53 | } 54 | 55 | entities.push(entity); 56 | } 57 | 58 | if (this.sortBy) { 59 | entities.sort(this.sortBy); 60 | } 61 | return entities; 62 | } 63 | 64 | getAll(options?: Partial>): V[] { 65 | const entities: V[] = []; 66 | for (let i = 0; i < this.ids.length; i++) { 67 | const entity = this.getOne(this.ids[i]); 68 | if (entity === undefined) { 69 | continue; 70 | } 71 | 72 | if (options?.filterBy && !options.filterBy(entity)) { 73 | continue; 74 | } 75 | 76 | entities.push(entity); 77 | } 78 | return entities; 79 | } 80 | 81 | setOne(entity: V): void { 82 | const id = this.getId(entity); 83 | const isNewEntity = !this.entityById.has(id); 84 | 85 | this.entityById.set(id, entity); 86 | if (isNewEntity) { 87 | this.ids.push(id); 88 | this.sort(); 89 | } 90 | } 91 | 92 | setMany(entities: V[]): void { 93 | if (this.sortBy) { 94 | entities.sort(this.sortBy); 95 | } 96 | 97 | const newIds: K[] = []; 98 | for (let i = 0; i < entities.length; i++) { 99 | const entityId = this.getId(entities[i]); 100 | if (!this.entityById.has(entityId)) { 101 | newIds.push(entityId); 102 | } 103 | this.entityById.set(entityId, entities[i]); 104 | } 105 | 106 | this.ids = this.ids.concat(newIds); 107 | this.sort(); 108 | } 109 | 110 | removeMany(ids: K[]): void { 111 | const uniqueRemovedIds = new Set(ids); 112 | 113 | const newIds: K[] = []; 114 | for (let i = 0; i < this.ids.length; i++) { 115 | if (!uniqueRemovedIds.has(this.ids[i])) { 116 | newIds.push(this.ids[i]); 117 | } 118 | } 119 | 120 | this.ids = newIds; 121 | for (let i = 0; i < ids.length; i++) { 122 | this.entityById.delete(ids[i]); 123 | } 124 | } 125 | 126 | removeAll(): void { 127 | this.ids = []; 128 | this.entityById.clear(); 129 | } 130 | 131 | sort() { 132 | if (!this.sortBy) { 133 | return; 134 | } 135 | 136 | this.ids.sort((id1, id2) => { 137 | // Typescript can't see the guard in the upper context 138 | if (!this.sortBy) { 139 | return 0; 140 | } 141 | 142 | const entity1 = this.getOne(id1); 143 | const entity2 = this.getOne(id2); 144 | 145 | // Should never happen 146 | if (entity1 === undefined || entity2 === undefined) { 147 | return 0; 148 | } 149 | 150 | return this.sortBy(entity1, entity2); 151 | }); 152 | } 153 | 154 | get size(): number { 155 | return this.entityById.size; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/simulator/types/web-worker-simulator/simulator.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from '../../../common'; 2 | import { ISimulator, ISimulationNode, ISimulationEdge, SimulatorEventType, SimulatorEvents } from '../../shared'; 3 | import { ID3SimulatorEngineSettingsUpdate } from '../../engine/d3-simulator-engine'; 4 | import { IWorkerInputPayload, WorkerInputType } from './message/worker-input'; 5 | import { IWorkerOutputPayload, WorkerOutputType } from './message/worker-output'; 6 | import { Emitter } from '../../../utils/emitter.utils'; 7 | 8 | export class WebWorkerSimulator extends Emitter implements ISimulator { 9 | protected readonly worker: Worker; 10 | 11 | constructor() { 12 | super(); 13 | this.worker = new Worker( 14 | new URL( 15 | /* webpackChunkName: 'process.worker' */ 16 | './process.worker', 17 | import.meta.url, 18 | ), 19 | { type: 'module' }, 20 | ); 21 | 22 | this.worker.onmessage = ({ data }: MessageEvent) => { 23 | switch (data.type) { 24 | case WorkerOutputType.SIMULATION_START: { 25 | this.emit(SimulatorEventType.SIMULATION_START, undefined); 26 | break; 27 | } 28 | case WorkerOutputType.SIMULATION_PROGRESS: { 29 | this.emit(SimulatorEventType.SIMULATION_PROGRESS, data.data); 30 | break; 31 | } 32 | case WorkerOutputType.SIMULATION_END: { 33 | this.emit(SimulatorEventType.SIMULATION_END, data.data); 34 | break; 35 | } 36 | case WorkerOutputType.NODE_DRAG: { 37 | this.emit(SimulatorEventType.NODE_DRAG, data.data); 38 | break; 39 | } 40 | case WorkerOutputType.NODE_DRAG_END: { 41 | this.emit(SimulatorEventType.NODE_DRAG_END, data.data); 42 | break; 43 | } 44 | case WorkerOutputType.SETTINGS_UPDATE: { 45 | this.emit(SimulatorEventType.SETTINGS_UPDATE, data.data); 46 | break; 47 | } 48 | } 49 | }; 50 | } 51 | 52 | setData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 53 | this.emitToWorker({ type: WorkerInputType.SetData, data: { nodes, edges } }); 54 | } 55 | 56 | addData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 57 | this.emitToWorker({ type: WorkerInputType.AddData, data: { nodes, edges } }); 58 | } 59 | 60 | updateData(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 61 | this.emitToWorker({ type: WorkerInputType.UpdateData, data: { nodes, edges } }); 62 | } 63 | 64 | clearData() { 65 | this.emitToWorker({ type: WorkerInputType.ClearData }); 66 | } 67 | 68 | simulate() { 69 | this.emitToWorker({ type: WorkerInputType.Simulate }); 70 | } 71 | 72 | activateSimulation() { 73 | this.emitToWorker({ type: WorkerInputType.ActivateSimulation }); 74 | } 75 | 76 | startSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 77 | this.emitToWorker({ type: WorkerInputType.StartSimulation, data: { nodes, edges } }); 78 | } 79 | 80 | updateSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]) { 81 | this.emitToWorker({ type: WorkerInputType.UpdateSimulation, data: { nodes, edges } }); 82 | } 83 | 84 | stopSimulation() { 85 | this.emitToWorker({ type: WorkerInputType.StopSimulation }); 86 | } 87 | 88 | startDragNode() { 89 | this.emitToWorker({ type: WorkerInputType.StartDragNode }); 90 | } 91 | 92 | dragNode(nodeId: number, position: IPosition) { 93 | this.emitToWorker({ type: WorkerInputType.DragNode, data: { id: nodeId, ...position } }); 94 | } 95 | 96 | endDragNode(nodeId: number) { 97 | this.emitToWorker({ type: WorkerInputType.EndDragNode, data: { id: nodeId } }); 98 | } 99 | 100 | fixNodes(nodes?: ISimulationNode[]) { 101 | this.emitToWorker({ type: WorkerInputType.FixNodes, data: { nodes } }); 102 | } 103 | 104 | releaseNodes(nodes?: ISimulationNode[]): void { 105 | this.emitToWorker({ type: WorkerInputType.ReleaseNodes, data: { nodes } }); 106 | } 107 | 108 | setSettings(settings: ID3SimulatorEngineSettingsUpdate) { 109 | this.emitToWorker({ type: WorkerInputType.SetSettings, data: settings }); 110 | } 111 | 112 | terminate() { 113 | this.worker.terminate(); 114 | this.removeAllListeners(); 115 | } 116 | 117 | protected emitToWorker(message: IWorkerInputPayload) { 118 | this.worker.postMessage(message); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/renderer/canvas/label.ts: -------------------------------------------------------------------------------- 1 | import { IPosition, Color } from '../../common'; 2 | 3 | const DEFAULT_FONT_FAMILY = 'Roboto, sans-serif'; 4 | const DEFAULT_FONT_SIZE = 4; 5 | const DEFAULT_FONT_COLOR = '#000000'; 6 | 7 | const FONT_BACKGROUND_MARGIN = 0.12; 8 | const FONT_LINE_SPACING = 1.2; 9 | 10 | export enum LabelTextBaseline { 11 | TOP = 'top', 12 | MIDDLE = 'middle', 13 | } 14 | 15 | export interface ILabelProperties { 16 | fontBackgroundColor: Color | string; 17 | fontColor: Color | string; 18 | fontFamily: string; 19 | fontSize: number; 20 | } 21 | 22 | export interface ILabelData { 23 | textBaseline: LabelTextBaseline; 24 | position: IPosition; 25 | properties: Partial; 26 | } 27 | 28 | export class Label { 29 | public readonly text: string; 30 | public readonly textLines: string[] = []; 31 | public readonly position: IPosition; 32 | public readonly properties: Partial; 33 | public readonly fontSize: number = DEFAULT_FONT_SIZE; 34 | public readonly fontFamily: string = getFontFamily(DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY); 35 | public readonly textBaseline: LabelTextBaseline; 36 | 37 | constructor(text: string, data: ILabelData) { 38 | this.text = `${text === undefined ? '' : text}`; 39 | this.textLines = splitTextLines(this.text); 40 | this.position = data.position; 41 | this.properties = data.properties; 42 | this.textBaseline = data.textBaseline; 43 | 44 | if (this.properties.fontSize !== undefined || this.properties.fontFamily) { 45 | this.fontSize = Math.max(this.properties.fontSize ?? 0, 0); 46 | this.fontFamily = getFontFamily(this.fontSize, this.properties.fontFamily ?? DEFAULT_FONT_FAMILY); 47 | } 48 | 49 | this._fixPosition(); 50 | } 51 | 52 | private _fixPosition() { 53 | if (this.textBaseline === LabelTextBaseline.MIDDLE && this.textLines.length) { 54 | const halfLineSpacingCount = Math.floor(this.textLines.length / 2); 55 | const halfLineCount = (this.textLines.length - 1) / 2; 56 | this.position.y -= halfLineCount * this.fontSize - halfLineSpacingCount * (FONT_LINE_SPACING - 1); 57 | } 58 | } 59 | } 60 | 61 | export const drawLabel = (context: CanvasRenderingContext2D, label: Label) => { 62 | const isDrawable = label.textLines.length > 0 && label.fontSize > 0; 63 | if (!isDrawable || !label.position) { 64 | return; 65 | } 66 | 67 | drawTextBackground(context, label); 68 | drawText(context, label); 69 | }; 70 | 71 | const drawTextBackground = (context: CanvasRenderingContext2D, label: Label) => { 72 | if (!label.properties.fontBackgroundColor || !label.position) { 73 | return; 74 | } 75 | 76 | context.fillStyle = label.properties.fontBackgroundColor.toString(); 77 | const margin = label.fontSize * FONT_BACKGROUND_MARGIN; 78 | const height = label.fontSize + 2 * margin; 79 | const lineHeight = label.fontSize * FONT_LINE_SPACING; 80 | const baselineHeight = label.textBaseline === LabelTextBaseline.MIDDLE ? label.fontSize / 2 : 0; 81 | 82 | for (let i = 0; i < label.textLines.length; i++) { 83 | const line = label.textLines[i]; 84 | const width = context.measureText(line).width + 2 * margin; 85 | context.fillRect( 86 | label.position.x - width / 2, 87 | label.position.y - baselineHeight - margin + i * lineHeight, 88 | width, 89 | height, 90 | ); 91 | } 92 | }; 93 | 94 | const drawText = (context: CanvasRenderingContext2D, label: Label) => { 95 | if (!label.position) { 96 | return; 97 | } 98 | 99 | context.fillStyle = (label.properties.fontColor ?? DEFAULT_FONT_COLOR).toString(); 100 | context.font = label.fontFamily; 101 | context.textBaseline = label.textBaseline; 102 | context.textAlign = 'center'; 103 | const lineHeight = label.fontSize * FONT_LINE_SPACING; 104 | 105 | for (let i = 0; i < label.textLines.length; i++) { 106 | const line = label.textLines[i]; 107 | context.fillText(line, label.position.x, label.position.y + i * lineHeight); 108 | } 109 | }; 110 | 111 | const getFontFamily = (fontSize: number, fontFamily: string): string => { 112 | return `${fontSize}px ${fontFamily}`; 113 | }; 114 | 115 | const splitTextLines = (text: string): string[] => { 116 | const lines = text.split('\n'); 117 | const trimmedLines: string[] = []; 118 | 119 | for (let i = 0; i < lines.length; i++) { 120 | const trimLine = lines[i].trim(); 121 | trimmedLines.push(trimLine); 122 | } 123 | 124 | return trimmedLines; 125 | }; 126 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/types/edge-curved.ts: -------------------------------------------------------------------------------- 1 | import { INode, INodeBase } from '../../../../models/node'; 2 | import { EdgeCurved, IEdgeBase } from '../../../../models/edge'; 3 | import { IBorderPosition, IEdgeArrow } from '../shared'; 4 | import { IPosition } from '../../../../common'; 5 | 6 | export const drawCurvedLine = ( 7 | context: CanvasRenderingContext2D, 8 | edge: EdgeCurved, 9 | ) => { 10 | const sourcePoint = edge.startNode.getCenter(); 11 | const targetPoint = edge.endNode.getCenter(); 12 | if (!sourcePoint || !targetPoint) { 13 | return; 14 | } 15 | 16 | const controlPoint = edge.getCurvedControlPoint(); 17 | 18 | context.beginPath(); 19 | context.moveTo(sourcePoint.x, sourcePoint.y); 20 | context.quadraticCurveTo(controlPoint.x, controlPoint.y, targetPoint.x, targetPoint.y); 21 | context.stroke(); 22 | }; 23 | 24 | /** 25 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/Edge.js} 26 | * 27 | * @param {EdgeCurved} edge Edge 28 | * @return {IEdgeArrow} Arrow shape 29 | */ 30 | export const getCurvedArrowShape = (edge: EdgeCurved): IEdgeArrow => { 31 | const scaleFactor = edge.style.arrowSize ?? 1; 32 | const lineWidth = edge.getWidth() ?? 1; 33 | const guideOffset = -0.1; 34 | // const source = this.data.source; 35 | const target = edge.endNode; 36 | 37 | const controlPoint = edge.getCurvedControlPoint(); 38 | const arrowPoint = findBorderPoint(edge, target); 39 | const guidePos = getPointBezier(edge, Math.max(0.0, Math.min(1.0, arrowPoint.t + guideOffset)), controlPoint); 40 | const angle = Math.atan2(arrowPoint.y - guidePos.y, arrowPoint.x - guidePos.x); 41 | 42 | const length = 1.5 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. 43 | 44 | const xi = arrowPoint.x - length * 0.9 * Math.cos(angle); 45 | const yi = arrowPoint.y - length * 0.9 * Math.sin(angle); 46 | const arrowCore = { x: xi, y: yi }; 47 | 48 | return { point: arrowPoint, core: arrowCore, angle, length }; 49 | }; 50 | 51 | /** 52 | * Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a 53 | * point on the line at a certain percentage of the way 54 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/edges/util/bezier-edge-base.ts} 55 | * 56 | * @param {EdgeCurved} edge Edge 57 | * @param {number} percentage Percentage of the line to get position from 58 | * @param {IPosition} viaNode Bezier node on the curved line 59 | * @return {IPosition} Position on the line 60 | */ 61 | const getPointBezier = ( 62 | edge: EdgeCurved, 63 | percentage: number, 64 | viaNode: IPosition, 65 | ): IPosition => { 66 | const sourcePoint = edge.startNode.getCenter(); 67 | const targetPoint = edge.endNode.getCenter(); 68 | if (!sourcePoint || !targetPoint) { 69 | return { x: 0, y: 0 }; 70 | } 71 | 72 | const t = percentage; 73 | const x = Math.pow(1 - t, 2) * sourcePoint.x + 2 * t * (1 - t) * viaNode.x + Math.pow(t, 2) * targetPoint.x; 74 | const y = Math.pow(1 - t, 2) * sourcePoint.y + 2 * t * (1 - t) * viaNode.y + Math.pow(t, 2) * targetPoint.y; 75 | 76 | return { x: x, y: y }; 77 | }; 78 | 79 | /** 80 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/edges/util/bezier-edge-base.ts} 81 | * 82 | * @param {EdgeCurved} edge Edge 83 | * @param {INode} nearNode Node close to the edge 84 | * @return {IBorderPosition} Position on the border of the node 85 | */ 86 | const findBorderPoint = ( 87 | edge: EdgeCurved, 88 | nearNode: INode, 89 | ): IBorderPosition => { 90 | const maxIterations = 10; 91 | let iteration = 0; 92 | let low = 0; 93 | let high = 1; 94 | let pos: IBorderPosition = { x: 0, y: 0, t: 0 }; 95 | // let angle; 96 | let distanceToBorder; 97 | let distanceToPoint; 98 | let difference; 99 | const threshold = 0.2; 100 | const viaNode = edge.getCurvedControlPoint(); 101 | let node = edge.endNode; 102 | let from = false; 103 | if (nearNode.id === edge.startNode.id) { 104 | node = edge.startNode; 105 | from = true; 106 | } 107 | 108 | const nodePoints = node.getCenter(); 109 | 110 | let middle; 111 | while (low <= high && iteration < maxIterations) { 112 | middle = (low + high) * 0.5; 113 | 114 | pos = { ...getPointBezier(edge, middle, viaNode), t: 0 }; 115 | // angle = Math.atan2(nodePoints.y - pos.y, nodePoints.x - pos.x); 116 | // distanceToBorder = node.getDistanceToBorder(angle); 117 | distanceToBorder = node.getDistanceToBorder(); 118 | distanceToPoint = Math.sqrt(Math.pow(pos.x - nodePoints.x, 2) + Math.pow(pos.y - nodePoints.y, 2)); 119 | difference = distanceToBorder - distanceToPoint; 120 | if (Math.abs(difference) < threshold) { 121 | break; // found 122 | } 123 | 124 | // distance to nodes is larger than distance to border --> t needs to be bigger if we're looking at the to node. 125 | if (difference < 0) { 126 | if (from === false) { 127 | low = middle; 128 | } else { 129 | high = middle; 130 | } 131 | } else { 132 | if (from === false) { 133 | high = middle; 134 | } else { 135 | low = middle; 136 | } 137 | } 138 | 139 | iteration++; 140 | } 141 | pos.t = middle ?? 0; 142 | 143 | return pos; 144 | }; 145 | -------------------------------------------------------------------------------- /examples/example-graph-events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orb | Handling graph events and callbacks 7 | 8 | 9 | 19 | 20 |
    21 |

    Example 4 - Events

    22 |

    23 | Renders a simple graph with few event listeners: Each graph simulation step 24 | is shown with progress indicator. On node click, node hover, and edge click, 25 | an event will be logged in the console with a message showing clicked/hovered 26 | node or edge. 27 |

    28 |
    < Click on any of the nodes or edges >
    29 |
    30 | 31 | 35 |
    36 |
    37 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![](./docs/assets/logo.png) 3 | 4 |

    5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

    18 | 19 | ![](./docs/assets/graph-example.png) 20 | 21 | Orb is a graph visualization library. Read more about Orb in the following guides: 22 | 23 | * [Handling nodes and edges](./docs/data.md) 24 | * [Styling nodes and edges](./docs/styles.md) 25 | * [Handling events](./docs/events.md) 26 | * Using different views 27 | * [Default view](./docs/view-default.md) 28 | * [Map view](./docs/view-map.md) 29 | 30 | ## Install 31 | 32 | > **Important note**: Please note that there might be breaking changes in minor version upgrades until 33 | > the Orb reaches version 1.0.0, so we recommend to either set strict version (`@memgraph/orb: "0.x.y"`) 34 | > of the Orb in your `package.json` or to allow only fix updates (`@memgraph/orb: "~0.x.y"`). 35 | 36 | ### With `npm` (recommended) 37 | 38 | ``` 39 | npm install @memgraph/orb 40 | ``` 41 | 42 | Below you can find a simple Typescript example using Orb to visualize a small graph. Feel 43 | free to check other JavaScript examples in `examples/` directory. 44 | 45 | ```typescript 46 | import { Orb } from '@memgraph/orb'; 47 | const container = document.getElementById('graph'); 48 | 49 | const nodes: MyNode[] = [ 50 | { id: 1, label: 'Orb' }, 51 | { id: 2, label: 'Graph' }, 52 | { id: 3, label: 'Canvas' }, 53 | ]; 54 | const edges: MyEdge[] = [ 55 | { id: 1, start: 1, end: 2, label: 'DRAWS' }, 56 | { id: 2, start: 2, end: 3, label: 'ON' }, 57 | ]; 58 | 59 | const orb = new Orb(container); 60 | 61 | // Initialize nodes and edges 62 | orb.data.setup({ nodes, edges }); 63 | 64 | // Render and recenter the view 65 | orb.view.render(() => { 66 | orb.view.recenter(); 67 | }); 68 | ``` 69 | 70 | ### With a direct link 71 | 72 | > Note: Simulation with web workers is not supported when Orb is used with a direct 73 | > link. Graph simulation will use the main thread, which will affect performance. 74 | 75 | ```html 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ``` 86 | 87 | Below you can find a simple JavaScript example using Orb to visualize a small graph. Feel 88 | free to check other JavaScript examples in `examples/` directory. 89 | 90 | ```html 91 | 92 | 93 | 94 | 95 | Orb | Simple graph 96 | 97 | 104 | 105 | 106 |
    107 | 131 | 132 | 133 | ``` 134 | 135 | ## Build 136 | 137 | ``` 138 | npm run build 139 | ``` 140 | 141 | ## Test 142 | 143 | ``` 144 | npm run test 145 | ``` 146 | 147 | ## Development 148 | 149 | If you want to experiment, contribute, or simply play with the Orb locally, you can 150 | set up your local development environment with: 151 | 152 | * Installation of all project dependencies 153 | 154 | ``` 155 | npm install 156 | ``` 157 | 158 | * Running webpack build in the watch mode 159 | 160 | ``` 161 | npm run webpack:watch 162 | ``` 163 | 164 | * Running a local http server that will serve Orb and `examples/` directory on `localhost:8080` 165 | 166 | ``` 167 | npm run serve 168 | ``` 169 | 170 | ## License 171 | 172 | Copyright (c) 2016-2022 [Memgraph Ltd.](https://memgraph.com) 173 | 174 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 175 | this file except in compliance with the License. You may obtain a copy of the 176 | License at 177 | 178 | http://www.apache.org/licenses/LICENSE-2.0 179 | 180 | Unless required by applicable law or agreed to in writing, software distributed 181 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 182 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 183 | specific language governing permissions and limitations under the License. 184 | -------------------------------------------------------------------------------- /src/renderer/canvas/node.ts: -------------------------------------------------------------------------------- 1 | import { INodeBase, INode, NodeShapeType } from '../../models/node'; 2 | import { IEdgeBase } from '../../models/edge'; 3 | import { drawDiamond, drawHexagon, drawSquare, drawStar, drawTriangleDown, drawTriangleUp, drawCircle } from './shapes'; 4 | import { drawLabel, Label, LabelTextBaseline } from './label'; 5 | 6 | // The label will be `X` of the size below the Node 7 | const DEFAULT_LABEL_DISTANCE_SIZE_FROM_NODE = 0.2; 8 | const DEFAULT_IS_SHADOW_DRAW_ENABLED = true; 9 | const DEFAULT_IS_LABEL_DRAW_ENABLED = true; 10 | 11 | export interface INodeDrawOptions { 12 | isShadowEnabled: boolean; 13 | isLabelEnabled: boolean; 14 | } 15 | 16 | export const drawNode = ( 17 | context: CanvasRenderingContext2D, 18 | node: INode, 19 | options?: Partial, 20 | ) => { 21 | const isShadowEnabled = options?.isShadowEnabled ?? DEFAULT_IS_SHADOW_DRAW_ENABLED; 22 | const isLabelEnabled = options?.isLabelEnabled ?? DEFAULT_IS_LABEL_DRAW_ENABLED; 23 | const hasShadow = node.hasShadow(); 24 | 25 | setupCanvas(context, node); 26 | if (isShadowEnabled && hasShadow) { 27 | setupShadow(context, node); 28 | } 29 | 30 | drawShape(context, node); 31 | context.fill(); 32 | 33 | const image = node.getBackgroundImage(); 34 | if (image) { 35 | drawImage(context, node, image); 36 | } 37 | 38 | if (isShadowEnabled && hasShadow) { 39 | clearShadow(context, node); 40 | } 41 | 42 | if (node.hasBorder()) { 43 | context.stroke(); 44 | } 45 | 46 | if (isLabelEnabled) { 47 | drawNodeLabel(context, node); 48 | } 49 | }; 50 | 51 | const drawShape = (context: CanvasRenderingContext2D, node: INode) => { 52 | // Default shape is the circle 53 | const center = node.getCenter(); 54 | const radius = node.getRadius(); 55 | 56 | switch (node.style.shape) { 57 | case NodeShapeType.SQUARE: { 58 | drawSquare(context, center.x, center.y, radius); 59 | break; 60 | } 61 | case NodeShapeType.DIAMOND: { 62 | drawDiamond(context, center.x, center.y, radius); 63 | break; 64 | } 65 | case NodeShapeType.TRIANGLE: { 66 | drawTriangleUp(context, center.x, center.y, radius); 67 | break; 68 | } 69 | case NodeShapeType.TRIANGLE_DOWN: { 70 | drawTriangleDown(context, center.x, center.y, radius); 71 | break; 72 | } 73 | case NodeShapeType.STAR: { 74 | drawStar(context, center.x, center.y, radius); 75 | break; 76 | } 77 | case NodeShapeType.HEXAGON: { 78 | drawHexagon(context, center.x, center.y, radius); 79 | break; 80 | } 81 | default: { 82 | drawCircle(context, center.x, center.y, radius); 83 | break; 84 | } 85 | } 86 | }; 87 | 88 | const drawNodeLabel = ( 89 | context: CanvasRenderingContext2D, 90 | node: INode, 91 | ) => { 92 | const nodeLabel = node.getLabel(); 93 | if (!nodeLabel) { 94 | return; 95 | } 96 | 97 | const center = node.getCenter(); 98 | const distance = node.getBorderedRadius() * (1 + DEFAULT_LABEL_DISTANCE_SIZE_FROM_NODE); 99 | 100 | const label = new Label(nodeLabel, { 101 | position: { x: center.x, y: center.y + distance }, 102 | textBaseline: LabelTextBaseline.TOP, 103 | properties: { 104 | fontBackgroundColor: node.style.fontBackgroundColor, 105 | fontColor: node.style.fontColor, 106 | fontFamily: node.style.fontFamily, 107 | fontSize: node.style.fontSize, 108 | }, 109 | }); 110 | drawLabel(context, label); 111 | }; 112 | 113 | const drawImage = ( 114 | context: CanvasRenderingContext2D, 115 | node: INode, 116 | image: HTMLImageElement, 117 | ) => { 118 | if (!image.width || !image.height) { 119 | return; 120 | } 121 | 122 | const center = node.getCenter(); 123 | const radius = node.getRadius(); 124 | 125 | const scale = Math.max((radius * 2) / image.width, (radius * 2) / image.height); 126 | const height = image.height * scale; 127 | const width = image.width * scale; 128 | 129 | context.save(); 130 | context.clip(); 131 | context.drawImage(image, center.x - width / 2, center.y - height / 2, width, height); 132 | context.restore(); 133 | }; 134 | 135 | const setupCanvas = ( 136 | context: CanvasRenderingContext2D, 137 | node: INode, 138 | ) => { 139 | const hasBorder = node.hasBorder(); 140 | 141 | if (hasBorder) { 142 | context.lineWidth = node.getBorderWidth(); 143 | const borderColor = node.getBorderColor(); 144 | if (borderColor) { 145 | context.strokeStyle = borderColor.toString(); 146 | } 147 | } 148 | 149 | const color = node.getColor(); 150 | if (color) { 151 | context.fillStyle = color.toString(); 152 | } 153 | }; 154 | 155 | const setupShadow = ( 156 | context: CanvasRenderingContext2D, 157 | node: INode, 158 | ) => { 159 | if (node.style.shadowColor) { 160 | context.shadowColor = node.style.shadowColor.toString(); 161 | } 162 | if (node.style.shadowSize) { 163 | context.shadowBlur = node.style.shadowSize; 164 | } 165 | if (node.style.shadowOffsetX) { 166 | context.shadowOffsetX = node.style.shadowOffsetX; 167 | } 168 | if (node.style.shadowOffsetY) { 169 | context.shadowOffsetY = node.style.shadowOffsetY; 170 | } 171 | }; 172 | 173 | const clearShadow = ( 174 | context: CanvasRenderingContext2D, 175 | node: INode, 176 | ) => { 177 | if (node.style.shadowColor) { 178 | context.shadowColor = 'rgba(0,0,0,0)'; 179 | } 180 | if (node.style.shadowSize) { 181 | context.shadowBlur = 0; 182 | } 183 | if (node.style.shadowOffsetX) { 184 | context.shadowOffsetX = 0; 185 | } 186 | if (node.style.shadowOffsetY) { 187 | context.shadowOffsetY = 0; 188 | } 189 | }; 190 | -------------------------------------------------------------------------------- /src/renderer/canvas/edge/base.ts: -------------------------------------------------------------------------------- 1 | import { INodeBase } from '../../../models/node'; 2 | import { IEdge, EdgeCurved, EdgeLoopback, EdgeStraight, IEdgeBase } from '../../../models/edge'; 3 | import { IPosition } from '../../../common'; 4 | import { drawLabel, Label, LabelTextBaseline } from '../label'; 5 | import { drawCurvedLine, getCurvedArrowShape } from './types/edge-curved'; 6 | import { drawLoopbackLine, getLoopbackArrowShape } from './types/edge-loopback'; 7 | import { drawStraightLine, getStraightArrowShape } from './types/edge-straight'; 8 | import { IEdgeArrow } from './shared'; 9 | 10 | const DEFAULT_IS_SHADOW_DRAW_ENABLED = true; 11 | const DEFAULT_IS_LABEL_DRAW_ENABLED = true; 12 | 13 | export interface IEdgeDrawOptions { 14 | isShadowEnabled: boolean; 15 | isLabelEnabled: boolean; 16 | } 17 | 18 | export const drawEdge = ( 19 | context: CanvasRenderingContext2D, 20 | edge: IEdge, 21 | options?: Partial, 22 | ) => { 23 | if (!edge.getWidth()) { 24 | return; 25 | } 26 | 27 | const isShadowEnabled = options?.isShadowEnabled ?? DEFAULT_IS_SHADOW_DRAW_ENABLED; 28 | const isLabelEnabled = options?.isLabelEnabled ?? DEFAULT_IS_LABEL_DRAW_ENABLED; 29 | const hasShadow = edge.hasShadow(); 30 | 31 | setupCanvas(context, edge); 32 | if (isShadowEnabled && hasShadow) { 33 | setupShadow(context, edge); 34 | } 35 | drawArrow(context, edge); 36 | drawLine(context, edge); 37 | if (isShadowEnabled && hasShadow) { 38 | clearShadow(context, edge); 39 | } 40 | 41 | if (isLabelEnabled) { 42 | drawEdgeLabel(context, edge); 43 | } 44 | }; 45 | 46 | const drawEdgeLabel = ( 47 | context: CanvasRenderingContext2D, 48 | edge: IEdge, 49 | ) => { 50 | const edgeLabel = edge.getLabel(); 51 | if (!edgeLabel) { 52 | return; 53 | } 54 | 55 | const label = new Label(edgeLabel, { 56 | position: edge.getCenter(), 57 | textBaseline: LabelTextBaseline.MIDDLE, 58 | properties: { 59 | fontBackgroundColor: edge.style.fontBackgroundColor, 60 | fontColor: edge.style.fontColor, 61 | fontFamily: edge.style.fontFamily, 62 | fontSize: edge.style.fontSize, 63 | }, 64 | }); 65 | drawLabel(context, label); 66 | }; 67 | 68 | const drawLine = (context: CanvasRenderingContext2D, edge: IEdge) => { 69 | if (edge instanceof EdgeStraight) { 70 | return drawStraightLine(context, edge); 71 | } 72 | if (edge instanceof EdgeCurved) { 73 | return drawCurvedLine(context, edge); 74 | } 75 | if (edge instanceof EdgeLoopback) { 76 | return drawLoopbackLine(context, edge); 77 | } 78 | 79 | throw new Error('Failed to draw unsupported edge type'); 80 | }; 81 | 82 | const drawArrow = (context: CanvasRenderingContext2D, edge: IEdge) => { 83 | if (edge.style.arrowSize === 0) { 84 | return; 85 | } 86 | 87 | const arrowShape = getArrowShape(edge); 88 | 89 | // Normalized points of closed path, in the order that they should be drawn. 90 | // (0, 0) is the attachment point, and the point around which should be rotated 91 | const keyPoints: IPosition[] = [ 92 | { x: 0, y: 0 }, 93 | { x: -1, y: 0.4 }, 94 | // { x: -0.9, y: 0 }, 95 | { x: -1, y: -0.4 }, 96 | ]; 97 | 98 | const points = transformArrowPoints(keyPoints, arrowShape); 99 | 100 | context.beginPath(); 101 | for (let i = 0; i < points.length; i++) { 102 | const point = points[i]; 103 | if (i === 0) { 104 | context.moveTo(point.x, point.y); 105 | continue; 106 | } 107 | context.lineTo(point.x, point.y); 108 | } 109 | context.closePath(); 110 | context.fill(); 111 | }; 112 | 113 | const getArrowShape = (edge: IEdge): IEdgeArrow => { 114 | if (edge instanceof EdgeStraight) { 115 | return getStraightArrowShape(edge); 116 | } 117 | if (edge instanceof EdgeCurved) { 118 | return getCurvedArrowShape(edge); 119 | } 120 | if (edge instanceof EdgeLoopback) { 121 | return getLoopbackArrowShape(edge); 122 | } 123 | 124 | throw new Error('Failed to draw unsupported edge type'); 125 | }; 126 | 127 | const setupCanvas = ( 128 | context: CanvasRenderingContext2D, 129 | edge: IEdge, 130 | ) => { 131 | const width = edge.getWidth(); 132 | if (width > 0) { 133 | context.lineWidth = width; 134 | } 135 | 136 | const color = edge.getColor(); 137 | // context.fillStyle is set for the sake of arrow colors 138 | if (color) { 139 | context.strokeStyle = color.toString(); 140 | context.fillStyle = color.toString(); 141 | } 142 | }; 143 | 144 | const setupShadow = ( 145 | context: CanvasRenderingContext2D, 146 | edge: IEdge, 147 | ) => { 148 | if (edge.style.shadowColor) { 149 | context.shadowColor = edge.style.shadowColor.toString(); 150 | } 151 | if (edge.style.shadowSize) { 152 | context.shadowBlur = edge.style.shadowSize; 153 | } 154 | if (edge.style.shadowOffsetX) { 155 | context.shadowOffsetX = edge.style.shadowOffsetX; 156 | } 157 | if (edge.style.shadowOffsetY) { 158 | context.shadowOffsetY = edge.style.shadowOffsetY; 159 | } 160 | }; 161 | 162 | const clearShadow = ( 163 | context: CanvasRenderingContext2D, 164 | edge: IEdge, 165 | ) => { 166 | if (edge.style.shadowColor) { 167 | context.shadowColor = 'rgba(0,0,0,0)'; 168 | } 169 | if (edge.style.shadowSize) { 170 | context.shadowBlur = 0; 171 | } 172 | if (edge.style.shadowOffsetX) { 173 | context.shadowOffsetX = 0; 174 | } 175 | if (edge.style.shadowOffsetY) { 176 | context.shadowOffsetY = 0; 177 | } 178 | }; 179 | 180 | /** 181 | * Apply transformation on points for display. 182 | * 183 | * @see {@link https://github.com/visjs/vis-network/blob/master/lib/network/modules/components/edges/util/end-points.ts} 184 | * 185 | * The following is done: 186 | * - rotate by the specified angle 187 | * - multiply the (normalized) coordinates by the passed length 188 | * - offset by the target coordinates 189 | * 190 | * @param {IPosition[]} points Arrow points 191 | * @param {IEdgeArrow} arrow Angle and length of the arrow shape 192 | * @return {IPosition[]} Transformed arrow points 193 | */ 194 | const transformArrowPoints = (points: IPosition[], arrow: IEdgeArrow): IPosition[] => { 195 | const x = arrow.point.x; 196 | const y = arrow.point.y; 197 | const angle = arrow.angle; 198 | const length = arrow.length; 199 | 200 | for (let i = 0; i < points.length; i++) { 201 | const p = points[i]; 202 | const xt = p.x * Math.cos(angle) - p.y * Math.sin(angle); 203 | const yt = p.x * Math.sin(angle) + p.y * Math.cos(angle); 204 | 205 | p.x = x + length * xt; 206 | p.y = y + length * yt; 207 | } 208 | 209 | return points; 210 | }; 211 | -------------------------------------------------------------------------------- /docs/view-map.md: -------------------------------------------------------------------------------- 1 | # Orb views: Map view 2 | 3 | By default, Orb offers a `MapView` which is a graph view with a map as a background. Map rendering is 4 | done with a library [leaflet](https://leafletjs.com/). To render maps, make sure to add the 5 | following CSS to your project: 6 | 7 | ```html 8 | 12 | ``` 13 | 14 | Here is a simple example of `MapView` usage: 15 | 16 | ![](./assets/view-map-example.png) 17 | 18 | ```typescript 19 | import { MapView } from "@memgraph/orb"; 20 | const container = document.getElementById(""); 21 | 22 | const nodes: MyNode[] = [ 23 | { id: "miami", label: "Miami", lat: 25.789106, lng: -80.226529 }, 24 | { id: "sanjuan", label: "San Juan", lat: 18.4663188, lng: -66.1057427 }, 25 | { id: "hamilton", label: "Hamilton", lat: 32.294887, lng: -64.78138 }, 26 | ]; 27 | const edges: MyEdge[] = [ 28 | { id: 0, start: "miami", end: "sanjuan" }, 29 | { id: 1, start: "sanjuan", end: "hamilton" }, 30 | { id: 2, start: "hamilton", end: "miami" }, 31 | ]; 32 | 33 | const orb = new Orb(container); 34 | orb.setView( 35 | (context) => 36 | new MapView(context, { 37 | getGeoPosition: (node) => ({ lat: node.data.lat, lng: node.data.lng }), 38 | }) 39 | ); 40 | 41 | // Assign a default style 42 | orb.data.setDefaultStyle({ 43 | getNodeStyle(node) { 44 | return { 45 | borderColor: "#FFFFFF", 46 | borderWidth: 1, 47 | color: "#DD2222", 48 | fontSize: 10, 49 | label: node.data.label, 50 | size: 10, 51 | }; 52 | }, 53 | getEdgeStyle() { 54 | return { 55 | arrowSize: 0, 56 | color: "#DD2222", 57 | width: 3, 58 | }; 59 | }, 60 | }); 61 | 62 | // Initialize nodes and edges 63 | orb.data.setup({ nodes, edges }); 64 | 65 | // Render and recenter the view 66 | orb.view.render(() => { 67 | orb.view.recenter(); 68 | }); 69 | ``` 70 | 71 | ## Initialization 72 | 73 | On `MapView` initialization, you must provide an implementation for `getGeoPosition` which is used 74 | to get `latitude` and `longitude` for each node. Here is the example of settings (required and optional) 75 | initialized on the new `MapView`: 76 | 77 | ```typescript 78 | import * as L from "leaflet"; 79 | import { MapView } from "@memgraph/orb"; 80 | 81 | const mapAttribution = 82 | 'Leaflet | ' + 83 | 'Map data © OpenStreetMap contributors'; 84 | 85 | orb.setView( 86 | (context) => 87 | new MapView(context, { 88 | getGeoPosition: (node) => ({ 89 | lat: node.data.latitude, 90 | lng: node.data.longitude, 91 | }), 92 | map: { 93 | zoomLevel: 5, 94 | tile: { 95 | instance: new L.TileLayer( 96 | "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" 97 | ), 98 | attribution: mapAttribution, 99 | }, 100 | render: { 101 | labelsIsEnabled: true, 102 | labelsOnEventIsEnabled: true, 103 | shadowIsEnabled: true, 104 | shadowOnEventIsEnabled: true, 105 | contextAlphaOnEvent: 0.3, 106 | contextAlphaOnEventIsEnabled: true, 107 | }, 108 | }, 109 | areCollapsedContainerDimensionsAllowed: false; 110 | }) 111 | ); 112 | ``` 113 | 114 | You can set settings on view initialization or afterward with `orb.view.setSettings`. Below 115 | you can see the list of all settings' parameters: 116 | 117 | ```typescript 118 | import * as L from "leaflet"; 119 | 120 | interface IMapViewSettings { 121 | // For map node positions 122 | getGeoPosition(node: INode): { lat: number; lng: number } | undefined; 123 | // For canvas rendering and events 124 | render: { 125 | fps: number; 126 | minZoom: number; 127 | maxZoom: number; 128 | fitZoomMargin: number; 129 | labelsIsEnabled: boolean; 130 | labelsOnEventIsEnabled: boolean; 131 | shadowIsEnabled: boolean; 132 | shadowOnEventIsEnabled: boolean; 133 | contextAlphaOnEvent: number; 134 | contextAlphaOnEventIsEnabled: boolean; 135 | backgroundColor: Color | string | null; 136 | }; 137 | // Other map view parameters 138 | map: { 139 | zoomLevel: number; 140 | tile: L.TileLayer; 141 | }; 142 | areCollapsedContainerDimensionsAllowed: boolean; 143 | } 144 | ``` 145 | 146 | The default settings that `MapView` uses is: 147 | 148 | ```typescript 149 | const defaultSettings = { 150 | render: { 151 | fps: 60, 152 | minZoom: 0.25, 153 | maxZoom: 8, 154 | fitZoomMargin: 0.2, 155 | labelsIsEnabled: true, 156 | labelsOnEventIsEnabled: true, 157 | shadowIsEnabled: true, 158 | shadowOnEventIsEnabled: true, 159 | contextAlphaOnEvent: 0.3, 160 | contextAlphaOnEventIsEnabled: true, 161 | backgroundColor: null, 162 | }, 163 | map: { 164 | zoomLevel: 2, // Default map zoom level 165 | tile: new L.TileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"), // OpenStreetMaps 166 | }, 167 | }; 168 | ``` 169 | 170 | You can read more about each property down below and on [Styles guide](./styles.md). 171 | 172 | ### Property `getGeoPosition` 173 | 174 | Property `getGeoPosition` is the only required one. It is a callback function that has a node (`INode`) 175 | as an input, and it needs to return the object `{ lat: number; lng: number; }` or `undefined`. If 176 | `undefined` is returned those nodes won't be rendered on the map. 177 | 178 | ### Property `map` 179 | 180 | Optional property `map` has two properties that you can set which are: 181 | 182 | - `zoomLevel` - initial map zoom level. The zoom level is forwarded to `leaflet`. 183 | - `tile` - map tile layout where you need to provide an instance (`leaflet.TileLayer`) and attribution. 184 | The default tile is the OpenStreetMaps. 185 | 186 | ### Property `render` 187 | 188 | Optional property `render` has several rendering options that you can tweak. Read more about them 189 | on [Styling guide](./styles.md). 190 | 191 | ### Property `areCollapsedContainerDimensionsAllowed` 192 | 193 | Enables setting the dimensions of the Orb container element to zero. 194 | If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`), 195 | Orb will expand the container by setting the values to `100%`. 196 | If that doesn't work (the parent of the container also has collapsed dimensions), 197 | Orb will set an arbitrary fixed dimension to the container. 198 | Disabled by default (`false`). 199 | 200 | ## Settings 201 | 202 | The above settings of `MapView` can be defined on view initialization, but also anytime after the 203 | initialization with a view function `setSettings`: 204 | 205 | ```typescript 206 | // If you want to see all the current view settings 207 | const settings = orb.view.getSettings(); 208 | 209 | // Change the way how geo coordinates are defined on nodes 210 | orb.view.setSettings({ 211 | getGeoPosition: (node) => ({ lat: node.data.lat, lng: node.data.lng }), 212 | }); 213 | 214 | // Change the zoom level and disable shadows 215 | orb.view.setSettings({ 216 | map: { 217 | zoomLevel: 7, 218 | }, 219 | render: { 220 | shadowIsEnabled: false, 221 | shadowOnEventIsEnabled: false, 222 | }, 223 | }); 224 | ``` 225 | 226 | ## Rendering 227 | 228 | Just like other Orb views, use `render` to render the view and `recenter` to fit the view to 229 | the rendered graph. 230 | 231 | ```typescript 232 | orb.view.render(() => { 233 | orb.view.recenter(); 234 | }); 235 | ``` 236 | 237 | ## Map reference `leaflet` 238 | 239 | If you need a reference to the internal map reference from `leaflet` library, just use the 240 | following example: 241 | 242 | ```typescript 243 | import { MapView } from "@memgraph/orb"; 244 | 245 | // It will only work on MapView 246 | const leaflet = (orb.view as MapView).leaflet; 247 | ``` 248 | -------------------------------------------------------------------------------- /src/models/strategy.ts: -------------------------------------------------------------------------------- 1 | import { INode, INodeBase } from './node'; 2 | import { IEdge, IEdgeBase } from './edge'; 3 | import { IGraph } from './graph'; 4 | import { IPosition } from '../common'; 5 | import { GraphObjectState } from './state'; 6 | 7 | export interface IEventStrategyResponse { 8 | isStateChanged: boolean; 9 | changedSubject?: INode | IEdge; 10 | } 11 | 12 | export interface IEventStrategy { 13 | onMouseClick: ((graph: IGraph, point: IPosition) => IEventStrategyResponse) | null; 14 | onMouseMove: ((graph: IGraph, point: IPosition) => IEventStrategyResponse) | null; 15 | onMouseRightClick: ((graph: IGraph, point: IPosition) => IEventStrategyResponse) | null; 16 | onMouseDoubleClick: ((graph: IGraph, point: IPosition) => IEventStrategyResponse) | null; 17 | } 18 | 19 | export const getDefaultEventStrategy = (): IEventStrategy => { 20 | return new DefaultEventStrategy(); 21 | }; 22 | 23 | class DefaultEventStrategy implements IEventStrategy { 24 | lastHoveredNode?: INode; 25 | 26 | onMouseClick(graph: IGraph, point: IPosition): IEventStrategyResponse { 27 | const node = graph.getNearestNode(point); 28 | if (node) { 29 | selectNode(graph, node); 30 | return { 31 | isStateChanged: true, 32 | changedSubject: node, 33 | }; 34 | } 35 | 36 | const edge = graph.getNearestEdge(point); 37 | if (edge) { 38 | selectEdge(graph, edge); 39 | return { 40 | isStateChanged: true, 41 | changedSubject: edge, 42 | }; 43 | } 44 | 45 | const { changedCount } = unselectAll(graph); 46 | return { 47 | isStateChanged: changedCount > 0, 48 | }; 49 | } 50 | 51 | onMouseMove(graph: IGraph, point: IPosition): IEventStrategyResponse { 52 | const node = graph.getNearestNode(point); 53 | if (node && !node.isSelected()) { 54 | if (node === this.lastHoveredNode) { 55 | return { 56 | changedSubject: node, 57 | isStateChanged: false, 58 | }; 59 | } 60 | 61 | hoverNode(graph, node); 62 | this.lastHoveredNode = node; 63 | return { 64 | isStateChanged: true, 65 | changedSubject: node, 66 | }; 67 | } 68 | 69 | this.lastHoveredNode = undefined; 70 | if (!node) { 71 | const { changedCount } = unhoverAll(graph); 72 | return { 73 | isStateChanged: changedCount > 0, 74 | }; 75 | } 76 | 77 | return { isStateChanged: false }; 78 | } 79 | 80 | onMouseRightClick(graph: IGraph, point: IPosition): IEventStrategyResponse { 81 | const node = graph.getNearestNode(point); 82 | if (node) { 83 | selectNode(graph, node); 84 | return { 85 | isStateChanged: true, 86 | changedSubject: node, 87 | }; 88 | } 89 | 90 | const edge = graph.getNearestEdge(point); 91 | if (edge) { 92 | selectEdge(graph, edge); 93 | return { 94 | isStateChanged: true, 95 | changedSubject: edge, 96 | }; 97 | } 98 | 99 | const { changedCount } = unselectAll(graph); 100 | return { 101 | isStateChanged: changedCount > 0, 102 | }; 103 | } 104 | 105 | onMouseDoubleClick(graph: IGraph, point: IPosition): IEventStrategyResponse { 106 | const node = graph.getNearestNode(point); 107 | if (node) { 108 | selectNode(graph, node); 109 | return { 110 | isStateChanged: true, 111 | changedSubject: node, 112 | }; 113 | } 114 | 115 | const edge = graph.getNearestEdge(point); 116 | if (edge) { 117 | selectEdge(graph, edge); 118 | return { 119 | isStateChanged: true, 120 | changedSubject: edge, 121 | }; 122 | } 123 | 124 | const { changedCount } = unselectAll(graph); 125 | return { 126 | isStateChanged: changedCount > 0, 127 | }; 128 | } 129 | } 130 | 131 | const selectNode = (graph: IGraph, node: INode) => { 132 | unselectAll(graph); 133 | setNodeState(node, GraphObjectState.SELECTED, { isStateOverride: true }); 134 | }; 135 | 136 | const selectEdge = (graph: IGraph, edge: IEdge) => { 137 | unselectAll(graph); 138 | setEdgeState(edge, GraphObjectState.SELECTED, { isStateOverride: true }); 139 | }; 140 | 141 | const unselectAll = (graph: IGraph): { changedCount: number } => { 142 | const selectedNodes = graph.getNodes((node) => node.isSelected()); 143 | for (let i = 0; i < selectedNodes.length; i++) { 144 | selectedNodes[i].clearState(); 145 | } 146 | 147 | const selectedEdges = graph.getEdges((edge) => edge.isSelected()); 148 | for (let i = 0; i < selectedEdges.length; i++) { 149 | selectedEdges[i].clearState(); 150 | } 151 | 152 | return { changedCount: selectedNodes.length + selectedEdges.length }; 153 | }; 154 | 155 | const hoverNode = (graph: IGraph, node: INode) => { 156 | unhoverAll(graph); 157 | setNodeState(node, GraphObjectState.HOVERED); 158 | }; 159 | 160 | // const hoverEdge = (graph: Graph, edge: Edge) => { 161 | // unhoverAll(graph); 162 | // setEdgeState(edge, GraphObjectState.HOVERED); 163 | // }; 164 | 165 | const unhoverAll = (graph: IGraph): { changedCount: number } => { 166 | const hoveredNodes = graph.getNodes((node) => node.isHovered()); 167 | for (let i = 0; i < hoveredNodes.length; i++) { 168 | hoveredNodes[i].clearState(); 169 | } 170 | 171 | const hoveredEdges = graph.getEdges((edge) => edge.isHovered()); 172 | for (let i = 0; i < hoveredEdges.length; i++) { 173 | hoveredEdges[i].clearState(); 174 | } 175 | 176 | return { changedCount: hoveredNodes.length + hoveredEdges.length }; 177 | }; 178 | 179 | interface ISetShapeStateOptions { 180 | isStateOverride: boolean; 181 | } 182 | 183 | const setNodeState = ( 184 | node: INode, 185 | state: number, 186 | options?: ISetShapeStateOptions, 187 | ): void => { 188 | if (isStateChangeable(node, options)) { 189 | node.state = state; 190 | } 191 | 192 | node.getInEdges().forEach((edge) => { 193 | if (edge && isStateChangeable(edge, options)) { 194 | edge.state = state; 195 | } 196 | if (edge.startNode && isStateChangeable(edge.startNode, options)) { 197 | edge.startNode.state = state; 198 | } 199 | }); 200 | 201 | node.getOutEdges().forEach((edge) => { 202 | if (edge && isStateChangeable(edge, options)) { 203 | edge.state = state; 204 | } 205 | if (edge.endNode && isStateChangeable(edge.endNode, options)) { 206 | edge.endNode.state = state; 207 | } 208 | }); 209 | }; 210 | 211 | const setEdgeState = ( 212 | edge: IEdge, 213 | state: number, 214 | options?: ISetShapeStateOptions, 215 | ): void => { 216 | if (isStateChangeable(edge, options)) { 217 | edge.state = state; 218 | } 219 | 220 | if (edge.startNode && isStateChangeable(edge.startNode, options)) { 221 | edge.startNode.state = state; 222 | } 223 | 224 | if (edge.endNode && isStateChangeable(edge.endNode, options)) { 225 | edge.endNode.state = state; 226 | } 227 | }; 228 | 229 | const isStateChangeable = ( 230 | graphObject: INode | IEdge, 231 | options?: ISetShapeStateOptions, 232 | ): boolean => { 233 | const isOverride = options?.isStateOverride; 234 | return isOverride || (!isOverride && !graphObject.state); 235 | }; 236 | -------------------------------------------------------------------------------- /src/utils/emitter.utils.ts: -------------------------------------------------------------------------------- 1 | // Reference: https://rjzaworski.com/2019/10/event-emitters-in-typescript 2 | export type IEventMap = Record; 3 | type IEventKey = string & keyof T; 4 | type IEventReceiver = (params: T) => void; 5 | 6 | export interface IEmitter { 7 | once>(eventName: K, func: IEventReceiver): IEmitter; 8 | on>(eventName: K, func: IEventReceiver): IEmitter; 9 | off>(eventName: K, func: IEventReceiver): IEmitter; 10 | emit>(eventName: K, params: T[K]): boolean; 11 | eventNames>(): K[]; 12 | listenerCount>(eventName: K): number; 13 | listeners>(eventName: K): IEventReceiver[]; 14 | addListener>(eventName: K, func: IEventReceiver): IEmitter; 15 | removeListener>(eventName: K, func: IEventReceiver): IEmitter; 16 | removeAllListeners>(eventName?: K): IEmitter; 17 | } 18 | 19 | interface IEmitterListener { 20 | callable: IEventReceiver; 21 | isOnce?: boolean; 22 | } 23 | 24 | export class Emitter implements IEmitter { 25 | private readonly _listeners = new Map, IEmitterListener[]>(); 26 | 27 | /** 28 | * Adds a one-time listener function for the event named eventName. The next time eventName is 29 | * triggered, this listener is removed and then invoked. 30 | * 31 | * @see {@link https://nodejs.org/api/events.html#emitteronceeventname-listener} 32 | * @param {IEventKey} eventName Event name 33 | * @param {IEventReceiver} func Event function 34 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 35 | */ 36 | once>(eventName: K, func: IEventReceiver): IEmitter { 37 | const newListener: IEmitterListener = { 38 | callable: func, 39 | isOnce: true, 40 | }; 41 | 42 | const listeners = this._listeners.get(eventName); 43 | if (listeners) { 44 | listeners.push(newListener); 45 | } else { 46 | this._listeners.set(eventName, [newListener]); 47 | } 48 | 49 | return this; 50 | } 51 | 52 | /** 53 | * Adds the listener function to the end of the listeners array for the event named eventName. 54 | * No checks are made to see if the listener has already been added. Multiple calls passing 55 | * the same combination of eventName and listener will result in the listener being added, 56 | * and called, multiple times. 57 | * 58 | * @see {@link https://nodejs.org/api/events.html#emitteroneventname-listener} 59 | * @param {IEventKey} eventName Event name 60 | * @param {IEventReceiver} func Event function 61 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 62 | */ 63 | on>(eventName: K, func: IEventReceiver): IEmitter { 64 | const newListener: IEmitterListener = { 65 | callable: func, 66 | }; 67 | 68 | const listeners = this._listeners.get(eventName); 69 | if (listeners) { 70 | listeners.push(newListener); 71 | } else { 72 | this._listeners.set(eventName, [newListener]); 73 | } 74 | 75 | return this; 76 | } 77 | 78 | /** 79 | * Removes the specified listener from the listener array for the event named eventName. 80 | * 81 | * @see {@link https://nodejs.org/api/events.html#emitterremovelistenereventname-listener} 82 | * @param {IEventKey} eventName Event name 83 | * @param {IEventReceiver} func Event function 84 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 85 | */ 86 | off>(eventName: K, func: IEventReceiver): IEmitter { 87 | const listeners = this._listeners.get(eventName); 88 | if (listeners) { 89 | const filteredListeners = listeners.filter((listener) => listener.callable !== func); 90 | this._listeners.set(eventName, filteredListeners); 91 | } 92 | 93 | return this; 94 | } 95 | 96 | /** 97 | * Synchronously calls each of the listeners registered for the event named eventName, 98 | * in the order they were registered, passing the supplied arguments to each. 99 | * Returns true if the event had listeners, false otherwise. 100 | * 101 | * @param {IEventKey} eventName Event name 102 | * @param {any} params Event parameters 103 | * 104 | * @return {boolean} True if the event had listeners, false otherwise 105 | */ 106 | emit>(eventName: K, params: T[K]): boolean { 107 | const listeners = this._listeners.get(eventName); 108 | if (!listeners || listeners.length === 0) { 109 | return false; 110 | } 111 | 112 | let hasOnceListener = false; 113 | for (let i = 0; i < listeners.length; i++) { 114 | if (listeners[i].isOnce) { 115 | hasOnceListener = true; 116 | } 117 | listeners[i].callable(params); 118 | } 119 | 120 | if (hasOnceListener) { 121 | const filteredListeners = listeners.filter((listener) => !listener.isOnce); 122 | this._listeners.set(eventName, filteredListeners); 123 | } 124 | return true; 125 | } 126 | 127 | /** 128 | * Returns an array listing the events for which the emitter has registered listeners. 129 | * 130 | * @see {@link https://nodejs.org/api/events.html#emittereventnames} 131 | * @return {IEventKey[]} Event names with registered listeners 132 | */ 133 | eventNames>(): K[] { 134 | return [...this._listeners.keys()] as K[]; 135 | } 136 | 137 | /** 138 | * Returns the number of listeners listening to the event named eventName. 139 | * 140 | * @see {@link https://nodejs.org/api/events.html#emitterlistenercounteventname} 141 | * @param {IEventKey} eventName Event name 142 | * @return {number} Number of listeners listening to the event name 143 | */ 144 | listenerCount>(eventName: K): number { 145 | const listeners = this._listeners.get(eventName); 146 | return listeners ? listeners.length : 0; 147 | } 148 | 149 | /** 150 | * Returns a copy of the array of listeners for the event named eventName. 151 | * 152 | * @see {@link https://nodejs.org/api/events.html#emitterlistenerseventname} 153 | * @param {IEventKey} eventName Event name 154 | * @return {IEventReceiver[]} Array of listeners for the event name 155 | */ 156 | listeners>(eventName: K): IEventReceiver[] { 157 | const listeners = this._listeners.get(eventName); 158 | if (!listeners) { 159 | return []; 160 | } 161 | return listeners.map((listener) => listener.callable); 162 | } 163 | 164 | /** 165 | * Alias for emitter.on(eventName, listener). 166 | * 167 | * @see {@link https://nodejs.org/api/events.html#emitteraddlistenereventname-listener} 168 | * @param {IEventKey} eventName Event name 169 | * @param {IEventReceiver} func Event function 170 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 171 | */ 172 | addListener>(eventName: K, func: IEventReceiver): IEmitter { 173 | return this.on(eventName, func); 174 | } 175 | 176 | /** 177 | * Alias for emitter.off(eventName, listener). 178 | * 179 | * @see {@link https://nodejs.org/api/events.html#emitterremovelistenereventname-listener} 180 | * @param {IEventKey} eventName Event name 181 | * @param {IEventReceiver} func Event function 182 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 183 | */ 184 | removeListener>(eventName: K, func: IEventReceiver): IEmitter { 185 | return this.off(eventName, func); 186 | } 187 | 188 | /** 189 | * Removes all listeners, or those of the specified eventName. 190 | * 191 | * @see {@link https://nodejs.org/api/events.html#emitterremovealllistenerseventname} 192 | * @param {IEventKey} eventName Event name 193 | * @return {IEmitter} Reference to the EventEmitter, so that calls can be chained 194 | */ 195 | removeAllListeners>(eventName?: K): IEmitter { 196 | if (eventName) { 197 | this._listeners.delete(eventName); 198 | } else { 199 | this._listeners.clear(); 200 | } 201 | 202 | return this; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/renderer/canvas/shapes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Draws a circle shape. 3 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 4 | * 5 | * @param {CanvasRenderingContext2D} context Canvas rendering context 6 | * @param {number} x Horizontal center 7 | * @param {number} y Vertical center 8 | * @param {number} r Radius 9 | */ 10 | export const drawCircle = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 11 | context.beginPath(); 12 | context.arc(x, y, r, 0, 2 * Math.PI, false); 13 | context.closePath(); 14 | }; 15 | 16 | /** 17 | * Draws a square shape. 18 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 19 | * 20 | * @param {CanvasRenderingContext2D} context Canvas rendering context 21 | * @param {number} x Horizontal center 22 | * @param {number} y Vertical center 23 | * @param {number} r Size (width and height) of the square 24 | */ 25 | export const drawSquare = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 26 | context.beginPath(); 27 | context.rect(x - r, y - r, r * 2, r * 2); 28 | context.closePath(); 29 | }; 30 | 31 | /** 32 | * Draws a triangle shape. 33 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 34 | * 35 | * @param {CanvasRenderingContext2D} context Canvas rendering context 36 | * @param {number} x Horizontal center 37 | * @param {number} y Vertical center 38 | * @param {number} r Radius, half the length of the sides of the triangle 39 | */ 40 | export const drawTriangleUp = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 41 | // http://en.wikipedia.org/wiki/Equilateral_triangle 42 | context.beginPath(); 43 | 44 | // the change in radius and the offset is here to center the shape 45 | r *= 1.15; 46 | y += 0.275 * r; 47 | 48 | const diameter = r * 2; 49 | const innerRadius = (Math.sqrt(3) * diameter) / 6; 50 | const height = Math.sqrt(diameter * diameter - r * r); 51 | 52 | context.moveTo(x, y - (height - innerRadius)); 53 | context.lineTo(x + r, y + innerRadius); 54 | context.lineTo(x - r, y + innerRadius); 55 | context.lineTo(x, y - (height - innerRadius)); 56 | context.closePath(); 57 | }; 58 | 59 | /** 60 | * Draws a triangle shape in downward orientation. 61 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 62 | * 63 | * @param {CanvasRenderingContext2D} context Canvas rendering context 64 | * @param {number} x Horizontal center 65 | * @param {number} y Vertical center 66 | * @param {number} r Radius, half the length of the sides of the triangle 67 | */ 68 | export const drawTriangleDown = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 69 | // http://en.wikipedia.org/wiki/Equilateral_triangle 70 | context.beginPath(); 71 | 72 | // the change in radius and the offset is here to center the shape 73 | r *= 1.15; 74 | y -= 0.275 * r; 75 | 76 | const diameter = r * 2; 77 | const innerRadius = (Math.sqrt(3) * diameter) / 6; 78 | const height = Math.sqrt(diameter * diameter - r * r); 79 | 80 | context.moveTo(x, y + (height - innerRadius)); 81 | context.lineTo(x + r, y - innerRadius); 82 | context.lineTo(x - r, y - innerRadius); 83 | context.lineTo(x, y + (height - innerRadius)); 84 | context.closePath(); 85 | }; 86 | 87 | /** 88 | * Draw a star shape, a star with 5 points. 89 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 90 | * 91 | * @param {CanvasRenderingContext2D} context Canvas rendering context 92 | * @param {number} x Horizontal center 93 | * @param {number} y Vertical center 94 | * @param {number} r Radius, half the length of the sides of the triangle 95 | */ 96 | export const drawStar = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 97 | // http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/ 98 | context.beginPath(); 99 | 100 | // the change in radius and the offset is here to center the shape 101 | r *= 0.82; 102 | y += 0.1 * r; 103 | 104 | for (let n = 0; n < 10; n++) { 105 | const radius = r * (n % 2 === 0 ? 1.3 : 0.5); 106 | const newx = x + radius * Math.sin((n * 2 * Math.PI) / 10); 107 | const newy = y - radius * Math.cos((n * 2 * Math.PI) / 10); 108 | context.lineTo(newx, newy); 109 | } 110 | 111 | context.closePath(); 112 | }; 113 | 114 | /** 115 | * Draws a Diamond shape. 116 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 117 | * 118 | * @param {CanvasRenderingContext2D} context Canvas rendering context 119 | * @param {number} x Horizontal center 120 | * @param {number} y Vertical center 121 | * @param {number} r Radius, half the length of the sides of the triangle 122 | */ 123 | export const drawDiamond = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 124 | // http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/ 125 | context.beginPath(); 126 | 127 | context.lineTo(x, y + r); 128 | context.lineTo(x + r, y); 129 | context.lineTo(x, y - r); 130 | context.lineTo(x - r, y); 131 | 132 | context.closePath(); 133 | }; 134 | 135 | /** 136 | * Draws a rounded rectangle. 137 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 138 | * @see {@link http://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas} 139 | * 140 | * @param {CanvasRenderingContext2D} context Canvas rendering context 141 | * @param {number} x Horizontal center 142 | * @param {number} y Vertical center 143 | * @param {number} w Width 144 | * @param {number} h Height 145 | * @param {number} r Border radius 146 | */ 147 | export const drawRoundRect = ( 148 | context: CanvasRenderingContext2D, 149 | x: number, 150 | y: number, 151 | w: number, 152 | h: number, 153 | r: number, 154 | ) => { 155 | const r2d = Math.PI / 180; 156 | 157 | // ensure that the radius isn't too large for x 158 | if (w - 2 * r < 0) { 159 | r = w / 2; 160 | } 161 | 162 | // ensure that the radius isn't too large for y 163 | if (h - 2 * r < 0) { 164 | r = h / 2; 165 | } 166 | 167 | context.beginPath(); 168 | context.moveTo(x + r, y); 169 | context.lineTo(x + w - r, y); 170 | context.arc(x + w - r, y + r, r, r2d * 270, r2d * 360, false); 171 | context.lineTo(x + w, y + h - r); 172 | context.arc(x + w - r, y + h - r, r, 0, r2d * 90, false); 173 | context.lineTo(x + r, y + h); 174 | context.arc(x + r, y + h - r, r, r2d * 90, r2d * 180, false); 175 | context.lineTo(x, y + r); 176 | context.arc(x + r, y + r, r, r2d * 180, r2d * 270, false); 177 | context.closePath(); 178 | }; 179 | 180 | /** 181 | * Draws an ellipse. 182 | * @see {@link https://github.com/almende/vis/blob/master/lib/network/shapes.js} 183 | * @see {@link http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas} 184 | * 185 | * @param {CanvasRenderingContext2D} context Canvas rendering context 186 | * @param {number} x Horizontal center 187 | * @param {number} y Vertical center 188 | * @param {number} w Width 189 | * @param {number} h Height 190 | */ 191 | export const drawEllipse = (context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) => { 192 | const kappa = 0.5522848; 193 | const ox = (w / 2) * kappa; // control point offset horizontal 194 | const oy = (h / 2) * kappa; // control point offset vertical 195 | const xend = x + w; 196 | const yend = y + h; 197 | const xmiddle = x + w / 2; 198 | const ymiddle = y + h / 2; 199 | 200 | context.beginPath(); 201 | context.moveTo(x, ymiddle); 202 | context.bezierCurveTo(x, ymiddle - oy, xmiddle - ox, y, xmiddle, y); 203 | context.bezierCurveTo(xmiddle + ox, y, xend, ymiddle - oy, xend, ymiddle); 204 | context.bezierCurveTo(xend, ymiddle + oy, xmiddle + ox, yend, xmiddle, yend); 205 | context.bezierCurveTo(xmiddle - ox, yend, x, ymiddle + oy, x, ymiddle); 206 | context.closePath(); 207 | }; 208 | 209 | /** 210 | * Draws a Hexagon shape with 6 sides. 211 | * 212 | * @param {CanvasRenderingContext2D} context Canvas rendering context 213 | * @param {Number} x Horizontal center 214 | * @param {Number} y Vertical center 215 | * @param {Number} r Radius 216 | */ 217 | export const drawHexagon = (context: CanvasRenderingContext2D, x: number, y: number, r: number) => { 218 | drawNgon(context, x, y, r, 6); 219 | }; 220 | 221 | /** 222 | * Draws a N-gon shape with N sides. 223 | * 224 | * @param {CanvasRenderingContext2D} context Canvas rendering context 225 | * @param {Number} x Horizontal center 226 | * @param {Number} y Vertical center 227 | * @param {Number} r Radius 228 | * @param {Number} sides Number of sides 229 | */ 230 | export const drawNgon = (context: CanvasRenderingContext2D, x: number, y: number, r: number, sides: number) => { 231 | context.beginPath(); 232 | context.moveTo(x + r, y); 233 | 234 | const arcSide = (Math.PI * 2) / sides; 235 | for (let i = 1; i < sides; i++) { 236 | context.lineTo(x + r * Math.cos(arcSide * i), y + r * Math.sin(arcSide * i)); 237 | } 238 | 239 | context.closePath(); 240 | }; 241 | -------------------------------------------------------------------------------- /test/utils/entity.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from '../../src/utils/entity.utils'; 2 | 3 | interface ITestState { 4 | id: number; 5 | name: string; 6 | value?: number; 7 | } 8 | 9 | describe('entity.utils', () => { 10 | describe('EntityState', () => { 11 | describe('without sort', () => { 12 | const items: ITestState[] = [ 13 | { id: 1, name: 'One' }, 14 | { id: 2, name: 'Two' }, 15 | { id: 3, name: 'Three' }, 16 | ]; 17 | 18 | const createDefaultState = (initialItems?: ITestState[]) => { 19 | const state = new EntityState({ 20 | getId: (item) => item.id, 21 | }); 22 | 23 | if (initialItems) { 24 | state.setMany(initialItems); 25 | } 26 | 27 | return state; 28 | }; 29 | 30 | test('should have size zero on init', () => { 31 | const state = createDefaultState(); 32 | 33 | expect(state.size).toEqual(0); 34 | }); 35 | 36 | test('should create entities one by one', () => { 37 | const state = createDefaultState(); 38 | items.forEach((item) => { 39 | state.setOne(item); 40 | }); 41 | 42 | expect(state.size).toEqual(items.length); 43 | for (let i = 0; i < items.length; i++) { 44 | const item = items[i]; 45 | expect(state.getOne(item.id)?.name).toEqual(item.name); 46 | } 47 | expect(state.getAll()).toEqual(items); 48 | expect(state.getMany(items.map((i) => i.id))).toEqual(items); 49 | }); 50 | 51 | test('should create multiple entities in a single call', () => { 52 | const state = createDefaultState(); 53 | state.setMany(items); 54 | 55 | expect(state.size).toEqual(items.length); 56 | for (let i = 0; i < items.length; i++) { 57 | const item = items[i]; 58 | expect(state.getOne(item.id)?.name).toEqual(item.name); 59 | } 60 | expect(state.getAll()).toEqual(items); 61 | expect(state.getMany(items.map((i) => i.id))).toEqual(items); 62 | }); 63 | 64 | test('should overwrite entities with one single id one by one', () => { 65 | const state = new EntityState({ 66 | getId: () => 1, 67 | }); 68 | items.forEach((item) => { 69 | state.setOne(item); 70 | }); 71 | 72 | expect(state.size).toEqual(1); 73 | expect(state.getOne(1)?.name).toEqual(items[items.length - 1].name); 74 | expect(state.getOne(2)).toBeUndefined(); 75 | expect(state.getOne(3)).toBeUndefined(); 76 | }); 77 | 78 | test('should overwrite entities with one single id in a single call', () => { 79 | const state = new EntityState({ 80 | getId: () => 1, 81 | }); 82 | state.setMany(items); 83 | 84 | expect(state.size).toEqual(1); 85 | expect(state.getOne(1)?.name).toEqual(items[items.length - 1].name); 86 | expect(state.getOne(2)).toBeUndefined(); 87 | expect(state.getOne(3)).toBeUndefined(); 88 | }); 89 | 90 | test('should return empty array for missing ids', () => { 91 | const state = createDefaultState(items); 92 | const maxId = Math.max(...items.map((item) => item.id)); 93 | 94 | expect(state.getMany([maxId + 1, maxId + 2, maxId + 3])).toEqual([]); 95 | }); 96 | 97 | test('should return filtered items by custom function', () => { 98 | const state = createDefaultState(items); 99 | const filterBy = (item: ITestState) => item.name.length > 3; 100 | const filteredItems = items.filter(filterBy); 101 | 102 | expect(state.getAll({ filterBy })).toEqual(filteredItems); 103 | }); 104 | 105 | test('should remove found distinct entities', () => { 106 | const state = createDefaultState(items); 107 | const removeIds = [5, 2, 4, 2]; 108 | const existingItems = items.filter((item) => !removeIds.includes(item.id)); 109 | state.removeMany(removeIds); 110 | 111 | expect(state.size).toEqual(existingItems.length); 112 | for (let i = 0; i < existingItems.length; i++) { 113 | const item = existingItems[i]; 114 | expect(state.getOne(item.id)?.name).toEqual(item.name); 115 | } 116 | expect(state.getAll()).toEqual(existingItems); 117 | expect(state.getMany(existingItems.map((i) => i.id))).toEqual(existingItems); 118 | }); 119 | 120 | test('should remove all', () => { 121 | const state = createDefaultState(items); 122 | state.removeAll(); 123 | 124 | expect(state.size).toEqual(0); 125 | }); 126 | }); 127 | 128 | describe('with sort', () => { 129 | const items: ITestState[] = [ 130 | { id: 1, name: 'One' }, 131 | { id: 2, name: 'Two', value: 10 }, 132 | { id: 3, name: 'Three', value: 1 }, 133 | { id: 4, name: 'Four' }, 134 | { id: 5, name: 'Five', value: 1 }, 135 | ]; 136 | const sortBy = (item1: ITestState, item2: ITestState) => (item1.value ?? 0) - (item2.value ?? 0); 137 | const sortedItems = items.sort(sortBy); 138 | 139 | const createDefaultState = (initialItems?: ITestState[]) => { 140 | const state = new EntityState({ 141 | getId: (item) => item.id, 142 | sortBy, 143 | }); 144 | 145 | if (initialItems) { 146 | state.setMany(initialItems); 147 | } 148 | 149 | return state; 150 | }; 151 | 152 | test('should have size zero on init', () => { 153 | const state = createDefaultState(); 154 | 155 | expect(state.size).toEqual(0); 156 | }); 157 | 158 | test('should create entities one by one', () => { 159 | const state = createDefaultState(); 160 | items.forEach((item) => { 161 | state.setOne(item); 162 | }); 163 | 164 | expect(state.size).toEqual(items.length); 165 | for (let i = 0; i < items.length; i++) { 166 | const item = items[i]; 167 | expect(state.getOne(item.id)?.name).toEqual(item.name); 168 | } 169 | expect(state.getAll()).toEqual(sortedItems); 170 | expect(state.getMany(items.map((i) => i.id))).toEqual(sortedItems); 171 | }); 172 | 173 | test('should create multiple entities in a single call', () => { 174 | const state = createDefaultState(); 175 | state.setMany(items); 176 | 177 | expect(state.size).toEqual(items.length); 178 | for (let i = 0; i < items.length; i++) { 179 | const item = items[i]; 180 | expect(state.getOne(item.id)?.name).toEqual(item.name); 181 | } 182 | expect(state.getAll()).toEqual(sortedItems); 183 | expect(state.getMany(items.map((i) => i.id))).toEqual(sortedItems); 184 | }); 185 | 186 | test('should overwrite entities with one single id one by one', () => { 187 | const state = new EntityState({ 188 | getId: () => 1, 189 | sortBy, 190 | }); 191 | items.forEach((item) => { 192 | state.setOne(item); 193 | }); 194 | 195 | expect(state.size).toEqual(1); 196 | expect(state.getOne(1)?.name).toEqual(items[items.length - 1].name); 197 | expect(state.getOne(2)).toBeUndefined(); 198 | expect(state.getOne(3)).toBeUndefined(); 199 | }); 200 | 201 | test('should overwrite entities with one single id in a single call', () => { 202 | const state = new EntityState({ 203 | getId: () => 1, 204 | sortBy, 205 | }); 206 | const finalItem = sortedItems[sortedItems.length - 1]; 207 | state.setMany(items); 208 | 209 | expect(state.size).toEqual(1); 210 | expect(state.getOne(1)?.name).toEqual(finalItem.name); 211 | expect(state.getOne(2)).toBeUndefined(); 212 | expect(state.getOne(3)).toBeUndefined(); 213 | }); 214 | 215 | test('should return empty array for missing ids', () => { 216 | const state = createDefaultState(items); 217 | const maxId = Math.max(...items.map((item) => item.id)); 218 | 219 | expect(state.getMany([maxId + 1, maxId + 2, maxId + 3])).toEqual([]); 220 | }); 221 | 222 | test('should return filtered items by custom function', () => { 223 | const state = createDefaultState(items); 224 | const filterBy = (item: ITestState) => item.name.length > 3; 225 | const filteredItems = sortedItems.filter(filterBy); 226 | 227 | expect(state.getAll({ filterBy })).toEqual(filteredItems); 228 | }); 229 | 230 | test('should remove found distinct entities', () => { 231 | const state = createDefaultState(items); 232 | const removeIds = [7, 2, 4, 2, 1, 1]; 233 | const existingItems = sortedItems.filter((item) => !removeIds.includes(item.id)); 234 | state.removeMany(removeIds); 235 | 236 | expect(state.size).toEqual(existingItems.length); 237 | for (let i = 0; i < existingItems.length; i++) { 238 | const item = existingItems[i]; 239 | expect(state.getOne(item.id)?.name).toEqual(item.name); 240 | } 241 | expect(state.getAll()).toEqual(existingItems); 242 | expect(state.getMany(existingItems.map((i) => i.id))).toEqual(existingItems); 243 | }); 244 | 245 | test('should remove all', () => { 246 | const state = createDefaultState(items); 247 | state.removeAll(); 248 | 249 | expect(state.size).toEqual(0); 250 | }); 251 | }); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /docs/data.md: -------------------------------------------------------------------------------- 1 | Handling graph data in Orb 2 | === 3 | 4 | Graph data structure (nodes and edges) is the main part of Orb. Without the graph data 5 | structure, there wouldn't be anything to render. Read the following guide to get to know 6 | how to handle graph data in Orb. 7 | 8 | > Note: Please do not use `node.addEdge` and `node.removeEdge` because the general graph data 9 | > structure might go out of sync. Always use `orb.data.(setup|merge|remove)` to change the 10 | > graph data structure. 11 | 12 | ## Setup nodes and edges 13 | 14 | To initialize graph data structure use `orb.data.setup` function that receives `nodes` and 15 | `edges`. Here is a simple example of it: 16 | 17 | ```typescript 18 | const orb = new Orb(container); 19 | 20 | const nodes: MyNode[] = [ 21 | { id: 0, text: "Node A", myField: 12 }, 22 | { id: 1, text: "Node B", myField: 77 }, 23 | ]; 24 | 25 | const edges: MyEdge[] = [ 26 | { id: 0, start: 0, end: 1, connects: 'A -> B' }, 27 | { id: 1, start: 0, end: 0, connects: 'A -> A' }, 28 | ]; 29 | 30 | orb.data.setup({ nodes, edges }); 31 | ``` 32 | 33 | To set up `nodes` and `edges`, there are a few requirements that Orb expects: 34 | 35 | * Node data object should be a JSON plain object with a defined unique `id`. The value of `id` can 36 | be `any`. All other properties are up to you (e.g. `text` and `myField` in the above example). 37 | * Edge data object should be a JSON plain object with defined unique `id`, `start` (id of 38 | the source node), `end` (id of the target node). The value of `id` can be `any`. All other 39 | properties are up to you. (e.g. `connects` in the above example). 40 | 41 | Whenever `orb.data.setup` is called, any previous graph structure will be removed. 42 | 43 | ### Node 44 | 45 | Node object (interface `INode`) is created on top of the node data that is provided via 46 | `orb.data.setup` or `orb.data.merge` functions. The Node object contains the information: 47 | 48 | * `id` - Readonly unique `id` provided on init (same as `.data.id`) 49 | * `data` - User provided information on `orb.data.setup` or `orb.data.merge` 50 | * `style` - Style properties like color, border, size (check more on [Styling guide](./styles.md)). 51 | * `position` - Node `x` and `y` coordinate generated before first render 52 | * `state` - Node state which can be selected (`GraphObjectState.SELECTED`), hovered 53 | (`GraphObjectState.HOVERED`), or none (`GraphObjectState.NONE` - default) 54 | 55 | There are some useful node functions that you can use such as: 56 | 57 | * `getCenter()` - Alias for `.position` 58 | * `getRadius()` - Alias for `.style.size` 59 | * `getBorderedRadius()` - Alias for `.style.size + .style.borderWidth` 60 | * `getInEdges()` - Returns a list of inbound edges connected to the node 61 | * `getOutEdges()` - Returns a list of outbound edges connected to the node 62 | * `getEdges()` - Returns a list of all edges connected to the node, inbound and outbound 63 | * `getAdjacentNodes()` - Returns a list of adjacent nodes 64 | 65 | Check the example to get to know node handling better: 66 | 67 | ```typescript 68 | const orb = new Orb(container); 69 | 70 | const nodes: MyNode[] = [ 71 | { id: 0, text: "Node A", myField: 12 }, 72 | { id: 1, text: "Node B", myField: 77 }, 73 | ]; 74 | 75 | orb.data.setup({ nodes }); 76 | 77 | const node = orb.data.getNodeById(0); 78 | console.log(node.id); // Output: 0 79 | console.log(node.data); // Output: { id: 0, text: "Node A", myField: 12 } 80 | 81 | // Set node color to red 82 | node.style.color = '#FF0000'; 83 | console.log(node.style); // Output: { ..., color: '#FF0000' } 84 | ``` 85 | 86 | ### Edge 87 | 88 | Edge object (interface `IEdge`) is created on top of the edge data that is provided via 89 | `orb.data.setup` or `orb.data.merge` functions. The Edge object contains the information: 90 | 91 | * `id` - Readonly unique `id` provided on init (same as `.data.id`) 92 | * `data` - User provided information on `orb.data.setup` or `orb.data.merge` 93 | * `start` - Readonly `start` provided on init (same as `.data.start`) 94 | * `end` - Readonly `end` provided on init (same as `.data.end`) 95 | * `startNode` - Reference to the start node (`INode`) that edge connects 96 | * `endNode` - Reference to the end node (`INode`) that edge connects 97 | * `style` - Style properties like color, border, size (check more on [Styling guide](./styles.md)). 98 | * `state` - Edge state which can be selected (`GraphObjectState.SELECTED`), hovered 99 | (`GraphObjectState.HOVERED`), or none (`GraphObjectState.NONE` - default) 100 | * `type` - Edge line type which can be: 101 | * straight (`EdgeType.STRAIGHT`) - if there are 1x, 3x, 5x, ... edges connecting nodes A and B, 102 | one edge will be a straight line edge. If there are multiple edges, other edges will be curved 103 | not to overlap with each other 104 | * curved (`EdgeType.CURVED`) - if there is more than one edge connecting nodes A and B, some 105 | of those edges will be curved, so they do not overlap with each other 106 | * loopback (`EdgeType.LOOPBACK) - connects a node to itself 107 | 108 | There are some useful node functions that you can use such as: 109 | 110 | * `getCenter()` - Gets the center edge position calculated by edge type and connected node positions 111 | * `getWidth()` - Alias for `.style.width` 112 | * `isLoopback()` - Checks if edge is a loopback type: connects a node to itself. 113 | * `isStraight()` - Checks if edge is a straight line edge 114 | * `isCurved()` - Checks if edge is a curved line edge. 115 | 116 | Check the example to get to know edge handling better: 117 | 118 | ```typescript 119 | const orb = new Orb(container); 120 | 121 | const nodes: MyNode[] = [ 122 | { id: 0, text: "Node A", myField: 12 }, 123 | { id: 1, text: "Node B", myField: 77 }, 124 | ]; 125 | 126 | const edges: MyEdge[] = [ 127 | { id: 0, start: 0, end: 1, connects: 'A -> B' }, 128 | { id: 1, start: 0, end: 0, connects: 'A -> A' }, 129 | ]; 130 | 131 | orb.data.setup({ nodes, edges }); 132 | 133 | const edge = orb.data.geEdgeById(0); 134 | console.log(edge.id); // Output: 0 135 | console.log(edge.data); // Output: { id: 0, start: 0, end: 1, connects: 'A -> B' } 136 | console.log(edge.startNode.data); // Output: { id: 0, text: "Node A", myField: 12 } 137 | console.log(edge.endNode.data); // Output: { id: 1, text: "Node B", myField: 77 } 138 | 139 | // Set edge line color to red 140 | edge.style.color = '#FF0000'; 141 | console.log(edge.style); // Output: { ..., color: '#FF0000' } 142 | ``` 143 | 144 | ## Merge nodes and edges 145 | 146 | Merge `orb.data.merge` is a handy function to add new nodes and edges or even update the existing 147 | ones. An update of a node or edge will happen if a node or edge with the same unique `id` already 148 | exists in the graph structure. Check the example below: 149 | 150 | ```typescript 151 | const orb = new Orb(container); 152 | 153 | const nodes: MyNode[] = [ 154 | { id: 0, text: "Node A", myField: 12 }, 155 | { id: 1, text: "Node B", myField: 77 }, 156 | ]; 157 | 158 | const edges: MyEdge[] = [ 159 | { id: 0, start: 0, end: 1, connects: 'A -> B' }, 160 | { id: 1, start: 0, end: 0, connects: 'A -> A' }, 161 | ]; 162 | 163 | orb.data.setup({ nodes, edges }); 164 | console.log(orb.data.getNodeCount()); // Output: 3 165 | console.log(orb.data.getNodeById(1)); // Output: { id: 1, text: "Node B", myField: 77 } 166 | 167 | orb.data.merge({ 168 | nodes: [ 169 | // This will be a new node in the graph because node with id = 2 doesn't exist 170 | { id: 2, text: "Node C", myField: 82 }, 171 | // This will update the node with id = 1 because it already exists. `node.data` will be updated. 172 | { id: 1, text: "Node D", myField: 82 }, 173 | ], 174 | edges: [ 175 | // This will update the edge with id = 1 because it already exists. `edge.data` will be updated, 176 | // but also, edge will disconnect from previous nodes and connect to the new ones (0 -> 2). 177 | { id: 1, start: 0, end: 2, connects: 'A -> C' }, 178 | // This will be a new edge in the graph because edge with id = 2 doesn't exist 179 | { id: 2, start: 2, end: 1, connects: 'C -> B' }, 180 | // This edge will be dismissed because node with id = 7 doesn't exist 181 | { id: 3, start: 2, end: 8, connects: 'C -> ?' }, 182 | ], 183 | }); 184 | console.log(orb.data.getNodeCount()); // Output: 3 185 | console.log(orb.data.getNodeById(1)); // Output: { id: 1, text: "Node D", myField: 82 } 186 | ``` 187 | 188 | ## Remove nodes and edges 189 | 190 | To remove nodes or edges from a graph, you just need the `id`. Removing a node will also 191 | remove all inbound and outbound edges to that node. Removing an edge will just remove that edge. 192 | 193 | ```typescript 194 | const orb = new Orb(container); 195 | 196 | const nodes: MyNode[] = [ 197 | { id: 0, text: "Node A" }, 198 | { id: 1, text: "Node B" }, 199 | ]; 200 | 201 | const edges: MyEdge[] = [ 202 | { id: 0, start: 0, end: 1, text: 'A -> B' }, 203 | { id: 1, start: 0, end: 0, text: 'A -> A' }, 204 | ]; 205 | 206 | orb.data.setup({ nodes, edges }); 207 | 208 | // After the removal of node with id 0, both edges will be removed too because they are 209 | // connected to the removed edge 210 | orb.data.remove({ nodeIds: [0] }); 211 | ``` 212 | 213 | You can remove just nodes, edges, or both: 214 | 215 | ```typescript 216 | // Remove just one node 217 | orb.data.remove({ nodeIds: [0] }); 218 | 219 | // Remove multiple nodes and one edge 220 | orb.data.remove({ nodeIds: [0, 1, 2], edgeIds: [0] }); 221 | 222 | // Remove just edges 223 | orb.data.remove({ edgeIds: [0, 1, 2 ] }); 224 | ``` 225 | 226 | If you need to remove everything, you can do it with `remove` or even with `setup`: 227 | 228 | ```typescript 229 | const nodeIds = orb.data.getNodes().map(node => node.id); 230 | // No need to get edges because if we remove all the nodes, all the edges will be removed too 231 | orb.data.remove({ nodeIds: nodeIds }); 232 | 233 | // Or use just setup with empty nodes and edges: 234 | orb.data.setup({ nodes: [], edges: [] }); 235 | ``` 236 | 237 | ## Other functions 238 | 239 | There are only three main functions to change the graph structure: `setup`, `merge`, and `remove`. 240 | But couple more functions could be useful to you: 241 | 242 | ```typescript 243 | // Returns the list of all nodes/edges in the graph 244 | const nodes = orb.data.getNodes(); 245 | const edges = orb.data.getEdges(); 246 | 247 | // Returns the total number of nodes/edges in the graph 248 | const nodeCount = orb.data.getNodeCount(); 249 | const edgeCount = orb.data.getEdgeCount(); 250 | 251 | // Returns specific node or edge by id. If node or edge doesn't exist, it will return undefined 252 | const node = orb.data.getNodeById(0); 253 | const edge = orb.data.getEdgeById(0); 254 | 255 | // Get nearest node/edge to the position (x, y). Useful with events such as mouse click to 256 | // check if node should be considered clicked or not 257 | const nearestNode = orb.data.getNearestNode({ x: 0, y: 0 }); 258 | const nearestEdge = orb.data.getNearestEdge({ x: 0, y: 0 }); 259 | ``` 260 | -------------------------------------------------------------------------------- /src/renderer/canvas/canvas-renderer.ts: -------------------------------------------------------------------------------- 1 | import { ZoomTransform, zoomIdentity } from 'd3-zoom'; 2 | import { IPosition, IRectangle } from '../../common'; 3 | import { INode, INodeBase, isNode } from '../../models/node'; 4 | import { IEdge, IEdgeBase } from '../../models/edge'; 5 | import { IGraph } from '../../models/graph'; 6 | import { drawEdge, IEdgeDrawOptions } from './edge'; 7 | import { drawNode, INodeDrawOptions } from './node'; 8 | import { Emitter } from '../../utils/emitter.utils'; 9 | import { 10 | DEFAULT_RENDERER_HEIGHT, 11 | DEFAULT_RENDERER_SETTINGS, 12 | DEFAULT_RENDERER_WIDTH, 13 | IRenderer, 14 | IRendererSettings, 15 | RendererEvents as RE, 16 | RenderEventType, 17 | } from '../shared'; 18 | import { throttle } from '../../utils/function.utils'; 19 | import { getThrottleMsFromFPS } from '../../utils/math.utils'; 20 | import { copyObject } from '../../utils/object.utils'; 21 | 22 | const DEBUG = false; 23 | const DEBUG_RED = '#FF5733'; 24 | const DEBUG_GREEN = '#3CFF33'; 25 | const DEBUG_BLUE = '#3383FF'; 26 | const DEBUG_PINK = '#F333FF'; 27 | 28 | export class CanvasRenderer extends Emitter implements IRenderer { 29 | // Contains the HTML5 Canvas element which is used for drawing nodes and edges. 30 | private readonly _context: CanvasRenderingContext2D; 31 | 32 | // Width and height of the canvas. Used for clearing 33 | public width: number; 34 | public height: number; 35 | private _settings: IRendererSettings; 36 | 37 | // Includes translation (pan) in the x and y direction 38 | // as well as scaling (level of zoom). 39 | public transform: ZoomTransform; 40 | 41 | // Translates (0, 0) coordinates to (width/2, height/2). 42 | private _isOriginCentered = false; 43 | 44 | // False if renderer never rendered on canvas, otherwise true 45 | private _isInitiallyRendered = false; 46 | 47 | private _throttleRender: (graph: IGraph) => void; 48 | 49 | constructor(context: CanvasRenderingContext2D, settings?: Partial) { 50 | super(); 51 | this._context = context; 52 | this.width = DEFAULT_RENDERER_WIDTH; 53 | this.height = DEFAULT_RENDERER_HEIGHT; 54 | this.transform = zoomIdentity; 55 | this._settings = { 56 | ...DEFAULT_RENDERER_SETTINGS, 57 | ...settings, 58 | }; 59 | 60 | this._throttleRender = throttle((graph: IGraph) => { 61 | this._render(graph); 62 | }, getThrottleMsFromFPS(this._settings.fps)); 63 | } 64 | 65 | get isInitiallyRendered(): boolean { 66 | return this._isInitiallyRendered; 67 | } 68 | 69 | getSettings(): IRendererSettings { 70 | return copyObject(this._settings); 71 | } 72 | 73 | setSettings(settings: Partial) { 74 | const isFpsChanged = settings.fps && settings.fps !== this._settings.fps; 75 | this._settings = { 76 | ...this._settings, 77 | ...settings, 78 | }; 79 | 80 | if (isFpsChanged) { 81 | this._throttleRender = throttle((graph: IGraph) => { 82 | this._render(graph); 83 | }, getThrottleMsFromFPS(this._settings.fps)); 84 | } 85 | } 86 | 87 | render(graph: IGraph) { 88 | this._throttleRender(graph); 89 | } 90 | 91 | private _render(graph: IGraph) { 92 | if (!graph.getNodeCount()) { 93 | return; 94 | } 95 | 96 | this.emit(RenderEventType.RENDER_START, undefined); 97 | const renderStartedAt = Date.now(); 98 | 99 | // Clear drawing. 100 | this._context.clearRect(0, 0, this.width, this.height); 101 | if (this._settings.backgroundColor) { 102 | this._context.fillStyle = this._settings.backgroundColor.toString(); 103 | this._context.fillRect(0, 0, this.width, this.height); 104 | } 105 | this._context.save(); 106 | 107 | if (DEBUG) { 108 | this._context.lineWidth = 3; 109 | this._context.fillStyle = DEBUG_RED; 110 | this._context.fillRect(0, 0, this.width, this.height); 111 | } 112 | 113 | // Apply any scaling (zoom) or translation (pan) transformations. 114 | this._context.translate(this.transform.x, this.transform.y); 115 | if (DEBUG) { 116 | this._context.fillStyle = DEBUG_BLUE; 117 | this._context.fillRect(0, 0, this.width, this.height); 118 | } 119 | 120 | this._context.scale(this.transform.k, this.transform.k); 121 | if (DEBUG) { 122 | this._context.fillStyle = DEBUG_GREEN; 123 | this._context.fillRect(0, 0, this.width, this.height); 124 | } 125 | 126 | // Move coordinates (0, 0) to canvas center. 127 | // Used in D3 graph, Map graph doesn't need centering. 128 | // This is only for display purposes, the simulation coordinates are still 129 | // relative to (0, 0), so any source mouse event position needs to take this 130 | // offset into account. (Handled in getMousePos()) 131 | if (this._isOriginCentered) { 132 | this._context.translate(this.width / 2, this.height / 2); 133 | } 134 | if (DEBUG) { 135 | this._context.fillStyle = DEBUG_PINK; 136 | this._context.fillRect(0, 0, this.width, this.height); 137 | } 138 | 139 | this.drawObjects(graph.getEdges()); 140 | this.drawObjects(graph.getNodes()); 141 | 142 | this._context.restore(); 143 | this.emit(RenderEventType.RENDER_END, { durationMs: Date.now() - renderStartedAt }); 144 | this._isInitiallyRendered = true; 145 | } 146 | 147 | private drawObjects(objects: (INode | IEdge)[]) { 148 | if (objects.length === 0) { 149 | return; 150 | } 151 | 152 | const selectedObjects: (INode | IEdge)[] = []; 153 | const hoveredObjects: (INode | IEdge)[] = []; 154 | 155 | for (let i = 0; i < objects.length; i++) { 156 | const obj = objects[i]; 157 | if (obj.isSelected()) { 158 | selectedObjects.push(obj); 159 | } 160 | if (obj.isHovered()) { 161 | hoveredObjects.push(obj); 162 | } 163 | } 164 | const hasStateChangedShapes = selectedObjects.length || hoveredObjects.length; 165 | 166 | if (this._settings.contextAlphaOnEventIsEnabled && hasStateChangedShapes) { 167 | this._context.globalAlpha = this._settings.contextAlphaOnEvent; 168 | } 169 | 170 | for (let i = 0; i < objects.length; i++) { 171 | const obj = objects[i]; 172 | if (!obj.isSelected() && !obj.isHovered()) { 173 | this.drawObject(obj, { 174 | isLabelEnabled: this._settings.labelsIsEnabled, 175 | isShadowEnabled: this._settings.shadowIsEnabled, 176 | }); 177 | } 178 | } 179 | 180 | if (this._settings.contextAlphaOnEventIsEnabled && hasStateChangedShapes) { 181 | this._context.globalAlpha = 1; 182 | } 183 | 184 | for (let i = 0; i < selectedObjects.length; i++) { 185 | this.drawObject(selectedObjects[i], { 186 | isLabelEnabled: this._settings.labelsOnEventIsEnabled, 187 | isShadowEnabled: this._settings.shadowOnEventIsEnabled, 188 | }); 189 | } 190 | for (let i = 0; i < hoveredObjects.length; i++) { 191 | this.drawObject(hoveredObjects[i], { 192 | isLabelEnabled: this._settings.labelsOnEventIsEnabled, 193 | isShadowEnabled: this._settings.shadowOnEventIsEnabled, 194 | }); 195 | } 196 | } 197 | 198 | private drawObject(obj: INode | IEdge, options?: Partial | Partial) { 199 | if (isNode(obj)) { 200 | drawNode(this._context, obj, options); 201 | } else { 202 | drawEdge(this._context, obj, options); 203 | } 204 | } 205 | 206 | reset() { 207 | this.transform = zoomIdentity; 208 | 209 | // Clear drawing. 210 | this._context.clearRect(0, 0, this.width, this.height); 211 | this._context.save(); 212 | } 213 | 214 | getFitZoomTransform(graph: IGraph): ZoomTransform { 215 | // Graph view is a bounding box of the graph nodes that takes into 216 | // account node positions (x, y) and node sizes (style: size + border width) 217 | const graphView = graph.getBoundingBox(); 218 | const graphMiddleX = graphView.x + graphView.width / 2; 219 | const graphMiddleY = graphView.y + graphView.height / 2; 220 | 221 | // Simulation view is actually a renderer view (canvas) but in the coordinate system of 222 | // the simulator: node position (x, y). We want to fit a graph view into a simulation view. 223 | const simulationView = this.getSimulationViewRectangle(); 224 | 225 | const heightScale = simulationView.height / (graphView.height * (1 + this._settings.fitZoomMargin)); 226 | const widthScale = simulationView.width / (graphView.width * (1 + this._settings.fitZoomMargin)); 227 | // The scale of the translation and the zoom needed to fit a graph view 228 | // into a simulation view (renderer canvas) 229 | const scale = Math.min(heightScale, widthScale); 230 | 231 | const previousZoom = this.transform.k; 232 | const newZoom = Math.max(Math.min(scale * previousZoom, this._settings.maxZoom), this._settings.minZoom); 233 | // Translation is done in the following way for both coordinates: 234 | // - M = expected movement to the middle of the view (simulation width or height / 2) 235 | // - Z(-1) = previous zoom level 236 | // - S = scale to fit the graph view into simulation view 237 | // - Z(0) = new zoom level / Z(0) := S * Z(-1) 238 | // - GM = current middle coordinate of the graph view 239 | // Formula: 240 | // X/Y := M * Z(-1) - M * Z(-1) * Z(0) - GM * Z(0) 241 | // X/Y := M * Z(-1) * (1 - Z(0)) - GM * Z(0) 242 | const newX = (simulationView.width / 2) * previousZoom * (1 - newZoom) - graphMiddleX * newZoom; 243 | const newY = (simulationView.height / 2) * previousZoom * (1 - newZoom) - graphMiddleY * newZoom; 244 | 245 | return zoomIdentity.translate(newX, newY).scale(newZoom); 246 | } 247 | 248 | getSimulationPosition(canvasPoint: IPosition): IPosition { 249 | // By default, the canvas is translated by (width/2, height/2) to center the graph. 250 | // The simulation is not, it's starting coordinates are at (0, 0). 251 | // So any mouse click (C) needs to subtract that translation to match the 252 | // simulation coordinates (O) when dragging and hovering nodes. 253 | const [x, y] = this.transform.invert([canvasPoint.x, canvasPoint.y]); 254 | return { 255 | x: x - this.width / 2, 256 | y: y - this.height / 2, 257 | }; 258 | } 259 | 260 | /** 261 | * Returns the visible rectangle view in the simulation coordinates. 262 | * 263 | * @return {IRectangle} Visible view in the simulation coordinates 264 | */ 265 | getSimulationViewRectangle(): IRectangle { 266 | const topLeftPosition = this.getSimulationPosition({ x: 0, y: 0 }); 267 | const bottomRightPosition = this.getSimulationPosition({ x: this.width, y: this.height }); 268 | return { 269 | x: topLeftPosition.x, 270 | y: topLeftPosition.y, 271 | width: bottomRightPosition.x - topLeftPosition.x, 272 | height: bottomRightPosition.y - topLeftPosition.y, 273 | }; 274 | } 275 | 276 | translateOriginToCenter() { 277 | this._isOriginCentered = true; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /docs/view-default.md: -------------------------------------------------------------------------------- 1 | # Orb views: Default view 2 | 3 | This is the default view that Orb uses to render a basic graph. 4 | 5 | ## Initialization 6 | 7 | The `DefaultView` is assigned to every instance of Orb by default. You don't need to 8 | provide any additional configuration to use it. 9 | 10 | You can, however, explicitly provide it in the factory function as you would any 11 | other type of `IOrbView`. This will be necessary if you want to assign fixed node 12 | coordinates, which you can read about further below. 13 | 14 | ```typescript 15 | import { DefaultView } from "@memgraph/orb"; 16 | 17 | const orb = new Orb(container); 18 | 19 | orb.setView((context) => new DefaultView(context, optionalSettings)); 20 | ``` 21 | 22 | You can set settings on view initialization or afterward with `orb.view.setSettings`. Below 23 | you can see the list of all settings' parameters: 24 | 25 | ```typescript 26 | interface IDefaultViewSettings { 27 | // For custom node positions 28 | getPosition(node: INode): { x: number; y: number } | undefined; 29 | // For node positioning simulation (d3-force parameters) 30 | simulation: { 31 | isPhysicsEnabled: false; 32 | alpha: { 33 | alpha: number; 34 | alphaMin: number; 35 | alphaDecay: number; 36 | alphaTarget: number; 37 | }; 38 | centering: null | { 39 | x: number; 40 | y: number; 41 | strength: number; 42 | }; 43 | collision: null | { 44 | radius: number; 45 | strength: number; 46 | iterations: number; 47 | }; 48 | links: { 49 | distance: number; 50 | strength?: number; 51 | iterations: number; 52 | }; 53 | manyBody: null | { 54 | strength: number; 55 | theta: number; 56 | distanceMin: number; 57 | distanceMax: number; 58 | }; 59 | positioning: null | { 60 | forceX: { 61 | x: number; 62 | strength: number; 63 | }; 64 | forceY: { 65 | y: number; 66 | strength: number; 67 | }; 68 | }; 69 | }; 70 | // For canvas rendering and events 71 | render: { 72 | fps: number; 73 | minZoom: number; 74 | maxZoom: number; 75 | fitZoomMargin: number; 76 | labelsIsEnabled: boolean; 77 | labelsOnEventIsEnabled: boolean; 78 | shadowIsEnabled: boolean; 79 | shadowOnEventIsEnabled: boolean; 80 | contextAlphaOnEvent: number; 81 | contextAlphaOnEventIsEnabled: boolean; 82 | backgroundColor: Color | string | null; 83 | }; 84 | // Other default view parameters 85 | zoomFitTransitionMs: number; 86 | isOutOfBoundsDragEnabled: boolean; 87 | areCoordinatesRounded: boolean; 88 | isSimulationAnimated: boolean; 89 | areCollapsedContainerDimensionsAllowed: boolean; 90 | } 91 | ``` 92 | 93 | The default settings that `DefaultView` uses is: 94 | 95 | ```typescript 96 | const defaultSettings = { 97 | simulation: { 98 | isPhysicsEnabled: false; 99 | alpha: { 100 | alpha: 1, 101 | alphaMin: 0.001, 102 | alphaDecay: 0.0228, 103 | alphaTarget: 0.1, 104 | }, 105 | centering: { 106 | x: 0, 107 | y: 0, 108 | strength: 1, 109 | }, 110 | collision: { 111 | radius: 15, 112 | strength: 1, 113 | iterations: 1, 114 | }, 115 | links: { 116 | distance: 30, 117 | iterations: 1, 118 | }, 119 | manyBody: { 120 | strength: -100, 121 | theta: 0.9, 122 | distanceMin: 0, 123 | distanceMax: 3000, 124 | }, 125 | positioning: { 126 | forceX: { 127 | x: 0, 128 | strength: 0.1, 129 | }, 130 | forceY: { 131 | y: 0, 132 | strength: 0.1, 133 | }, 134 | }, 135 | }, 136 | render: { 137 | fps: 60, 138 | minZoom: 0.25, 139 | maxZoom: 8, 140 | fitZoomMargin: 0.2, 141 | labelsIsEnabled: true, 142 | labelsOnEventIsEnabled: true, 143 | shadowIsEnabled: true, 144 | shadowOnEventIsEnabled: true, 145 | contextAlphaOnEvent: 0.3, 146 | contextAlphaOnEventIsEnabled: true, 147 | backgroundColor: null, 148 | }, 149 | zoomFitTransitionMs: 200, 150 | isOutOfBoundsDragEnabled: false, 151 | areCoordinatesRounded: true, 152 | isSimulationAnimated: true, 153 | areCollapsedContainerDimensionsAllowed: false; 154 | } 155 | ``` 156 | 157 | You can read more about each property down below and on [Styles guide](./styles.md). 158 | 159 | ### Property `getPosition` 160 | 161 | There are two basic ways to use the `DefaultView` API based on the node positions: 162 | 163 | - **Simulated node positions** - Orb internally calculates and assigns coordinates to 164 | your nodes. 165 | - **Fixed coordinates** - You provide node coordinates through the `getPosition()` 166 | callback function. 167 | 168 | #### Simulated node positions 169 | 170 | In this mode, the `DefaultView` arranges node positions by internally calculating their 171 | coordinates using the [D3.js](https://d3js.org/) library, or more specifically, 172 | [`d3-force`](https://github.com/d3/d3-force). This method is applied by default - you don't 173 | need to initialize Orb with any additional configuration. 174 | 175 | ![](./assets/view-default-simulated.png) 176 | 177 | ```typescript 178 | const nodes: MyNode[] = [ 179 | { id: 0, label: "Node A" }, 180 | { id: 1, label: "Node B" }, 181 | { id: 2, label: "Node C" }, 182 | ]; 183 | const edges: MyEdge[] = [ 184 | { id: 0, start: 0, end: 0, label: "Edge Q" }, 185 | { id: 1, start: 0, end: 1, label: "Edge W" }, 186 | { id: 2, start: 0, end: 2, label: "Edge E" }, 187 | { id: 3, start: 1, end: 2, label: "Edge T" }, 188 | { id: 4, start: 2, end: 2, label: "Edge Y" }, 189 | { id: 5, start: 0, end: 1, label: "Edge V" }, 190 | ]; 191 | 192 | const orb = new Orb(container); 193 | 194 | // Initialize nodes and edges 195 | orb.data.setup({ nodes, edges }); 196 | 197 | // Render and recenter the view 198 | orb.view.render(() => { 199 | orb.view.recenter(); 200 | }); 201 | ``` 202 | 203 | #### Fixed node positions 204 | 205 | If you want to assign specific coordinates to your graph, you can do this by providing the 206 | `getPosition()` callback function. You can use this function to indicate which properties of 207 | your original nodes will be in the returned `IPosition` object (`{ x: number, y: number }`) 208 | that allows Orb to position the nodes. 209 | 210 | ![](./assets/view-default-fixed.png) 211 | 212 | ```typescript 213 | import { DefaultView } from "@memgraph/orb"; 214 | const container = document.getElementById("graph"); 215 | 216 | const nodes: MyNode[] = [ 217 | { id: 0, label: "Node A", posX: -100, posY: 0 }, 218 | { id: 1, label: "Node B", posX: 100, posY: 0 }, 219 | { id: 2, label: "Node C", posX: 0, posY: 100 }, 220 | ]; 221 | const edges: MyEdge[] = [ 222 | { id: 0, start: 0, end: 0, label: "Edge Q" }, 223 | { id: 1, start: 0, end: 1, label: "Edge W" }, 224 | { id: 2, start: 0, end: 2, label: "Edge E" }, 225 | { id: 3, start: 1, end: 2, label: "Edge T" }, 226 | { id: 4, start: 2, end: 2, label: "Edge Y" }, 227 | { id: 5, start: 0, end: 1, label: "Edge V" }, 228 | ]; 229 | 230 | const orb = new Orb(container); 231 | orb.setView( 232 | (context) => 233 | new DefaultView(context, { 234 | getPosition: (node) => ({ x: node.data.posX, y: node.data.posY }), 235 | }) 236 | ); 237 | 238 | // Initialize nodes and edges 239 | orb.data.setup({ nodes, edges }); 240 | 241 | // Render and recenter the view 242 | orb.view.render(() => { 243 | orb.view.recenter(); 244 | }); 245 | ``` 246 | 247 | You can use this callback function to assign fixed coordinates to your nodes. 248 | 249 | The function has a node input (`INode`) which represents the Orb node data structure. You can 250 | access your original properties through `.data` property. There you can find all properties of 251 | your nodes that you assigned in the `orb.data.setup()` function. 252 | 253 | Here you can use your original properties to indicate which ones represent your node coordinates 254 | (`node.data.posX`, `node.data.posY`). All you have to do is return a `IPosition` that requires 255 | 2 basic properties: `x` and `y` (`{ x: node.data.posX, y: node.data.posY }`). 256 | 257 | ### Property `render` 258 | 259 | Optional property `render` has several rendering options that you can tweak. Read more about them 260 | on [Styling guide](./styles.md). 261 | 262 | ### Property `simulation` 263 | 264 | Fine-grained D3 simulation engine settings. They include `isPhysicsEnabled`, `alpha`, 265 | `centering`, `collision`, `links`, `manyBody`, and `positioning`. You can use `isPhysicsEnabled` 266 | to enable or disable physics. You can read more about the other settings on the official 267 | [`d3-force docs`](https://github.com/d3/d3-force). This may be condensed into fewer, more abstract 268 | settings in the future. 269 | 270 | ### Property `zoomFitTransitionMs` 271 | 272 | Use this property to adjust the transition time when re-centering the graph. Defaults to `200ms`. 273 | 274 | ### Property `isOutOfBoundsDragEnabled` 275 | 276 | Disabled by default (`false`). 277 | 278 | ### Property `areCoordinatesRounded` 279 | 280 | Rounds node coordinates to integer values. Slightly improves performance. Enabled by default (`true`). 281 | 282 | ### Property `isSimulationAnimated` 283 | 284 | Shows the process of simulation where the nodes are moved by the physics engine until they 285 | converge to a stable position. If disabled, the graph will suddenly appear in its final position. 286 | Enabled by default (`true`). 287 | 288 | ### Property `areCollapsedContainerDimensionsAllowed` 289 | 290 | Enables setting the dimensions of the Orb container element to zero. 291 | If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`), 292 | Orb will expand the container by setting the values to `100%`. 293 | If that doesn't work (the parent of the container also has collapsed dimensions), 294 | Orb will set an arbitrary fixed dimension to the container. 295 | Disabled by default (`false`). 296 | 297 | ## Settings 298 | 299 | The above settings of the `DefaultView` can be defined on view initialization, but also anytime 300 | after the initialization with a view function `setSettings`: 301 | 302 | ```typescript 303 | import { DefaultView } from "@memgraph/orb"; 304 | 305 | const orb = new Orb(container); 306 | 307 | orb.setView( 308 | (context) => 309 | new DefaultView(context, { 310 | getPosition: (node) => ({ x: node.data.posY, y: node.data.posX }), 311 | zoomFitTransformMs: 1000, 312 | render: { 313 | shadowIsEnabled: false, 314 | shadowOnEventIsEnabled: false, 315 | }, 316 | }) 317 | ); 318 | ``` 319 | 320 | ```typescript 321 | // If you want to see all the current view settings 322 | const settings = orb.view.getSettings(); 323 | 324 | // Change the x and y axis 325 | orb.view.setSettings({ 326 | getPosition: (node) => ({ x: node.data.posY, y: node.data.posX }), 327 | }); 328 | 329 | // Change the zoom fit and transform time while re-centering and disable shadows 330 | orb.view.setSettings({ 331 | zoomFitTransformMs: 1000, 332 | render: { 333 | shadowIsEnabled: false, 334 | shadowOnEventIsEnabled: false, 335 | }, 336 | }); 337 | ``` 338 | 339 | ## Rendering 340 | 341 | Just like other Orb views, use `render` to render the view and `recenter` to fit the view to 342 | the rendered graph. 343 | 344 | ```typescript 345 | orb.view.render(() => { 346 | orb.view.recenter(); 347 | }); 348 | ``` 349 | --------------------------------------------------------------------------------