From 2ac224b2735ceaf094b1bc4dc39b61b648d7c920 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 15 May 2025 07:22:47 +0200 Subject: [PATCH 01/15] New: Add new layouts --- src/models/graph.ts | 19 ++++++ src/simulator/layout/layout.ts | 44 ++++++++------ src/simulator/layout/layouts/circle.ts | 11 ---- src/simulator/layout/layouts/circular.ts | 25 ++++++++ src/simulator/layout/layouts/grid.ts | 28 +++++++++ src/simulator/layout/layouts/hierarchical.ts | 61 ++++++++++++++++++++ src/views/orb-view.ts | 11 ++++ 7 files changed, 169 insertions(+), 30 deletions(-) delete mode 100644 src/simulator/layout/layouts/circle.ts create mode 100644 src/simulator/layout/layouts/circular.ts create mode 100644 src/simulator/layout/layouts/grid.ts create mode 100644 src/simulator/layout/layouts/hierarchical.ts diff --git a/src/models/graph.ts b/src/models/graph.ts index 8b7801e..08ec223 100644 --- a/src/models/graph.ts +++ b/src/models/graph.ts @@ -8,6 +8,7 @@ import { IEntityState, EntityState } from '../utils/entity.utils'; import { IObserver, IObserverDataPayload, ISubject, Subject } from '../utils/observer.utils'; import { patchProperties } from '../utils/object.utils'; import { dedupArrays } from '../utils/array.utils'; +import { ILayout } from '../simulator/layout/layout'; export interface IGraphData { nodes: N[]; @@ -50,6 +51,7 @@ export interface IGraph extends ISubje getNearestNode(point: IPosition): INode | undefined; getNearestEdge(point: IPosition, minDistance?: number): IEdge | undefined; setSettings(settings: Partial>): void; + setLayout(layout: ILayout): void; } export interface IGraphSettings { @@ -73,6 +75,7 @@ export class Graph extends Subject imp }); private _defaultStyle?: Partial>; private _settings: IGraphSettings; + private _layout: ILayout | undefined; constructor(data?: Partial>, settings?: Partial>) { // TODO(dlozic): How to use object assign here? If I add add and export a default const here, it needs N, E. @@ -92,6 +95,22 @@ export class Graph extends Subject imp this.notifyListeners(); } + setLayout(layout: ILayout): void { + this._layout = layout; + console.log('Layout set', layout); + this.resetLayout(); + } + + resetLayout(): void { + if (!this._layout) { + return; + } + + const positions = this._layout.getPositions(this.getNodes()); + this.setNodePositions(positions); + this.notifyListeners(); + } + /** * Returns a list of nodes. * diff --git a/src/simulator/layout/layout.ts b/src/simulator/layout/layout.ts index defe2bc..520dd0a 100644 --- a/src/simulator/layout/layout.ts +++ b/src/simulator/layout/layout.ts @@ -1,29 +1,35 @@ import { IEdgeBase } from '../../models/edge'; import { INode, INodeBase, INodePosition } from '../../models/node'; -import { CircleLayout } from './layouts/circle'; +import { CircularLayout } from './layouts/circular'; +import { GridLayout } from './layouts/grid'; +import { HierarchicalLayout } from './layouts/hierarchical'; -export enum layouts { - DEFAULT = 'default', - CIRCLE = 'circle', +export type LayoutType = 'hierarchical' | 'circular' | 'grid'; + +export interface ILayoutSettings { + type: LayoutType; } export interface ILayout { - getPositions(nodes: INode[], width: number, height: number): INodePosition[]; + getPositions(nodes: INode[]): INodePosition[]; } -export class Layout implements ILayout { - private readonly _layout: ILayout | null; - - private layoutByLayoutName: Record | null> = { - [layouts.DEFAULT]: null, - [layouts.CIRCLE]: new CircleLayout(), - }; - - constructor(layoutName: string) { - this._layout = this.layoutByLayoutName[layoutName]; - } - - getPositions(nodes: INode[], width: number, height: number): INodePosition[] { - return this._layout === null ? [] : this._layout.getPositions(nodes, width, height); +// todo(Alex): add layout options +export class LayoutFactory { + static create( + type: LayoutType, + width: number, + height: number, + ): ILayout | null { + switch (type) { + case 'hierarchical': + return new HierarchicalLayout(width, height); + case 'circular': + return new CircularLayout(width, height); + case 'grid': + return new GridLayout(width, height); + default: + return null; + } } } diff --git a/src/simulator/layout/layouts/circle.ts b/src/simulator/layout/layouts/circle.ts deleted file mode 100644 index b601d78..0000000 --- a/src/simulator/layout/layouts/circle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IEdgeBase } from '../../../models/edge'; -import { INode, INodeBase, INodePosition } from '../../../models/node'; -import { ILayout } from '../layout'; - -export class CircleLayout implements ILayout { - getPositions(nodes: INode[], width: number, height: number): INodePosition[] { - return nodes.map((node, index) => { - return { id: node.id, x: width / 2, y: height - index * 10 }; - }); - } -} diff --git a/src/simulator/layout/layouts/circular.ts b/src/simulator/layout/layouts/circular.ts new file mode 100644 index 0000000..ff63b57 --- /dev/null +++ b/src/simulator/layout/layouts/circular.ts @@ -0,0 +1,25 @@ +import { IEdgeBase } from '../../../models/edge'; +import { INode, INodeBase, INodePosition } from '../../../models/node'; +import { ILayout } from '../layout'; + +export class CircularLayout implements ILayout { + constructor(private width: number, private height: number) { + this.width = width; + this.height = height; + } + + getPositions(nodes: INode[]): INodePosition[] { + const r = Math.min(this.width, this.height) / 2; + const centerX = this.width / 2; + const centerY = this.height / 2; + const angleStep = (2 * Math.PI) / nodes.length; + + return nodes.map((node) => { + return { + id: node.getId(), + x: centerX + r * Math.cos(angleStep * node.getId()), + y: centerY + r * Math.sin(angleStep * node.getId()), + }; + }); + } +} diff --git a/src/simulator/layout/layouts/grid.ts b/src/simulator/layout/layouts/grid.ts new file mode 100644 index 0000000..f4c6e9c --- /dev/null +++ b/src/simulator/layout/layouts/grid.ts @@ -0,0 +1,28 @@ +import { IEdgeBase } from '../../../models/edge'; +import { INode, INodeBase, INodePosition } from '../../../models/node'; +import { ILayout } from '../layout'; + +export class GridLayout implements ILayout { + constructor(private width: number, private height: number) { + this.width = width; + this.height = height; + } + + getPositions(nodes: INode[]): INodePosition[] { + const rows = Math.ceil(Math.sqrt(nodes.length)); + const cols = Math.ceil(nodes.length / rows); + const cellWidth = this.width / cols; + const cellHeight = this.height / rows; + + const positions: INodePosition[] = []; + nodes.forEach((node, index) => { + const row = Math.floor(index / cols); + const col = index % cols; + const x = col * cellWidth + cellWidth / 2; + const y = row * cellHeight + cellHeight / 2; + positions.push({ id: node.getId(), x, y }); + }); + + return positions; + } +} diff --git a/src/simulator/layout/layouts/hierarchical.ts b/src/simulator/layout/layouts/hierarchical.ts new file mode 100644 index 0000000..193cbd2 --- /dev/null +++ b/src/simulator/layout/layouts/hierarchical.ts @@ -0,0 +1,61 @@ +import { IEdgeBase } from '../../../models/edge'; +import { INode, INodeBase, INodePosition } from '../../../models/node'; +import { ILayout } from '../layout'; + +export class HierarchicalLayout implements ILayout { + constructor(private width: number, private height: number) { + this.width = width; + this.height = height; + } + + getPositions(nodes: INode[]): INodePosition[] { + if (nodes.length === 0) { + return []; + } + + const outEdgesCounts = nodes.map((node) => (node.getInEdges().length > 0 ? 0 : node.getOutEdges().length)); + const indexOfRoot = outEdgesCounts.indexOf(Math.max(...outEdgesCounts)); + const rootNode = nodes[indexOfRoot]; + + const depthMap = new Map, number>(); + const depthGroups = new Map[]>(); + const queue: INode[] = [rootNode]; + + depthMap.set(rootNode, 0); + depthGroups.set(0, [rootNode]); + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + break; + } + + const depth = depthMap.get(current) || 0; + const children = current + .getOutEdges() + .map((edge) => edge.endNode) + .concat(current.getInEdges().map((edge) => edge.startNode)); + + for (const child of children) { + if (!depthMap.has(child)) { + depthMap.set(child, depth + 1); + if (!depthGroups.has(depth + 1)) { + depthGroups.set(depth + 1, []); + } + depthGroups.get(depth + 1)?.push(child); + queue.push(child); + } + } + } + + const positions: INodePosition[] = []; + for (const [depth, nodes] of depthGroups.entries()) { + const y = (depth + 1) * (this.height / (depthGroups.size + 1)); + const xOffset = this.width / (nodes.length + 1); + nodes.forEach((node, index) => { + const x = (index + 1) * xOffset; + positions.push({ id: node.getId(), x, y }); + }); + } + return positions; + } +} diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 3b5711a..7e1b9f3 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -22,6 +22,7 @@ import { SimulatorEventType } from '../simulator/shared'; import { getDefaultGraphStyle } from '../models/style'; import { isBoolean } from '../utils/type.utils'; import { IObserver, IObserverDataPayload } from '../utils/observer.utils'; +import { ILayoutSettings, LayoutFactory } from '../simulator/layout/layout'; export interface IGraphInteractionSettings { isDragEnabled: boolean; @@ -34,6 +35,7 @@ export interface IOrbViewSettings { render: Partial; strategy: Partial; interaction: Partial; + layout: Partial; zoomFitTransitionMs: number; isOutOfBoundsDragEnabled: boolean; areCoordinatesRounded: boolean; @@ -72,6 +74,9 @@ export class OrbView implements IOrbVi isPhysicsEnabled: false, ...settings?.simulation, }, + layout: { + ...settings?.layout, + }, render: { ...settings?.render, }, @@ -193,6 +198,12 @@ export class OrbView implements IOrbVi const nodePositions = this._graph.getNodePositions(); const edgePositions = this._graph.getEdgePositions(); // this._onSimulationEnd = onRendered; + if (this._settings.layout.type) { + const layout = LayoutFactory.create(this._settings.layout.type, this._renderer.width, this._renderer.height); + if (layout) { + this._graph.setLayout(layout); + } + } this._simulator.setupData({ nodes: nodePositions, edges: edgePositions }); }, onMergeData: (data) => { From 43305a0a93701ca50355c526f551216cf624a522 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 15 May 2025 08:28:34 +0200 Subject: [PATCH 02/15] New: Add layout options --- src/simulator/layout/layout.ts | 20 +++++++---- src/simulator/layout/layouts/circular.ts | 24 +++++++++----- src/simulator/layout/layouts/hierarchical.ts | 35 ++++++++++++++++---- src/views/orb-view.ts | 4 +-- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/simulator/layout/layout.ts b/src/simulator/layout/layout.ts index 520dd0a..93f7881 100644 --- a/src/simulator/layout/layout.ts +++ b/src/simulator/layout/layout.ts @@ -1,31 +1,37 @@ import { IEdgeBase } from '../../models/edge'; import { INode, INodeBase, INodePosition } from '../../models/node'; -import { CircularLayout } from './layouts/circular'; +import { CircularLayout, ICircularLayoutOptions } from './layouts/circular'; import { GridLayout } from './layouts/grid'; -import { HierarchicalLayout } from './layouts/hierarchical'; +import { HierarchicalLayout, IHierarchicalLayoutOptions } from './layouts/hierarchical'; export type LayoutType = 'hierarchical' | 'circular' | 'grid'; +export type LayoutSettingsMap = { + hierarchical: IHierarchicalLayoutOptions; + circular: ICircularLayoutOptions; + grid: Record; +}; + export interface ILayoutSettings { type: LayoutType; + options?: LayoutSettingsMap[LayoutType]; } export interface ILayout { getPositions(nodes: INode[]): INodePosition[]; } -// todo(Alex): add layout options export class LayoutFactory { static create( - type: LayoutType, width: number, height: number, + settings?: Partial, ): ILayout | null { - switch (type) { + switch (settings?.type) { case 'hierarchical': - return new HierarchicalLayout(width, height); + return new HierarchicalLayout(width, height, settings.options as IHierarchicalLayoutOptions); case 'circular': - return new CircularLayout(width, height); + return new CircularLayout(width, height, settings.options as ICircularLayoutOptions); case 'grid': return new GridLayout(width, height); default: diff --git a/src/simulator/layout/layouts/circular.ts b/src/simulator/layout/layouts/circular.ts index ff63b57..c0dee9a 100644 --- a/src/simulator/layout/layouts/circular.ts +++ b/src/simulator/layout/layouts/circular.ts @@ -2,23 +2,31 @@ import { IEdgeBase } from '../../../models/edge'; import { INode, INodeBase, INodePosition } from '../../../models/node'; import { ILayout } from '../layout'; +export interface ICircularLayoutOptions { + radius?: number; +} + export class CircularLayout implements ILayout { - constructor(private width: number, private height: number) { - this.width = width; - this.height = height; + private _width: number; + private _height: number; + private _radius: number; + + constructor(width: number, height: number, options?: ICircularLayoutOptions) { + this._width = width; + this._height = height; + this._radius = options?.radius || Math.min(width, height) / 2; } getPositions(nodes: INode[]): INodePosition[] { - const r = Math.min(this.width, this.height) / 2; - const centerX = this.width / 2; - const centerY = this.height / 2; + const centerX = this._width / 2; + const centerY = this._height / 2; const angleStep = (2 * Math.PI) / nodes.length; return nodes.map((node) => { return { id: node.getId(), - x: centerX + r * Math.cos(angleStep * node.getId()), - y: centerY + r * Math.sin(angleStep * node.getId()), + x: centerX + this._radius * Math.cos(angleStep * node.getId()), + y: centerY + this._radius * Math.sin(angleStep * node.getId()), }; }); } diff --git a/src/simulator/layout/layouts/hierarchical.ts b/src/simulator/layout/layouts/hierarchical.ts index 193cbd2..60abe58 100644 --- a/src/simulator/layout/layouts/hierarchical.ts +++ b/src/simulator/layout/layouts/hierarchical.ts @@ -2,10 +2,24 @@ import { IEdgeBase } from '../../../models/edge'; import { INode, INodeBase, INodePosition } from '../../../models/node'; import { ILayout } from '../layout'; +export type HierarchicalLayoutOrientation = 'horizontal' | 'vertical'; + +export interface IHierarchicalLayoutOptions { + orientation?: HierarchicalLayoutOrientation; + reversed?: boolean; +} + export class HierarchicalLayout implements ILayout { - constructor(private width: number, private height: number) { - this.width = width; - this.height = height; + private _width: number; + private _height: number; + private _orientation: HierarchicalLayoutOrientation; + private _reversed: boolean; + + constructor(width: number, height: number, options?: IHierarchicalLayoutOptions) { + this._width = width; + this._height = height; + this._orientation = options?.orientation || 'vertical'; + this._reversed = options?.reversed || false; } getPositions(nodes: INode[]): INodePosition[] { @@ -49,11 +63,20 @@ export class HierarchicalLayout implem const positions: INodePosition[] = []; for (const [depth, nodes] of depthGroups.entries()) { - const y = (depth + 1) * (this.height / (depthGroups.size + 1)); - const xOffset = this.width / (nodes.length + 1); + let y = (depth + 1) * ((this._orientation === 'vertical' ? this._height : this._width) / (depthGroups.size + 1)); + const xOffset = (this._orientation === 'vertical' ? this._width : this._height) / (nodes.length + 1); + + if (this._reversed) { + y = (this._orientation === 'vertical' ? this._height : this._width) - y; + } + nodes.forEach((node, index) => { const x = (index + 1) * xOffset; - positions.push({ id: node.getId(), x, y }); + if (this._orientation === 'horizontal') { + positions.push({ id: node.getId(), x: y, y: x }); + } else { + positions.push({ id: node.getId(), x, y }); + } }); } return positions; diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 7e1b9f3..2ac34bc 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -198,8 +198,8 @@ export class OrbView implements IOrbVi const nodePositions = this._graph.getNodePositions(); const edgePositions = this._graph.getEdgePositions(); // this._onSimulationEnd = onRendered; - if (this._settings.layout.type) { - const layout = LayoutFactory.create(this._settings.layout.type, this._renderer.width, this._renderer.height); + if (this._settings.layout) { + const layout = LayoutFactory.create(this._renderer.width, this._renderer.height, this._settings.layout); if (layout) { this._graph.setLayout(layout); } From 77a61eb677ae556725bff5cc75419ee03a944e3f Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 15 May 2025 10:00:26 +0200 Subject: [PATCH 03/15] Chore: Make layout dynamically changeable --- src/views/orb-view.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 2ac34bc..c58becd 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -255,6 +255,18 @@ export class OrbView implements IOrbVi this._settings.render = this._renderer.getSettings(); } + if (settings.layout) { + this._settings.layout = { + ...this._settings.layout, + ...settings.layout, + }; + + const layout = LayoutFactory.create(this._renderer.width, this._renderer.height, this._settings.layout); + if (layout) { + this._graph.setLayout(layout); + } + } + if (settings.strategy) { if (isBoolean(settings.strategy.isDefaultHoverEnabled)) { this._settings.strategy.isDefaultHoverEnabled = settings.strategy.isDefaultHoverEnabled; From e9c4fe7bb16c676f0d1345726d374b742a9f8883 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 15 May 2025 10:32:17 +0200 Subject: [PATCH 04/15] Chore: Update docs --- docs/view-default.md | 38 +++++++++ examples/example-hierarchical-layout.html | 94 +++++++++++++++++++++++ examples/index.html | 6 ++ 3 files changed, 138 insertions(+) create mode 100644 examples/example-hierarchical-layout.html diff --git a/docs/view-default.md b/docs/view-default.md index 849796a..56e14d1 100644 --- a/docs/view-default.md +++ b/docs/view-default.md @@ -61,6 +61,16 @@ interface IOrbViewSettings { }; }; }; + // For graph layouting (if present, physics is disabled) + layout: { + type: 'hierarchical' | 'grid' | 'circular'; + options: null | { + orientation: 'vertical' | 'horizontal'; + reversed: boolean; + } | { + radius: number; + } + }; // For canvas rendering and events render: { devicePixelRatio: number | null; @@ -266,6 +276,34 @@ Here you can use your original properties to indicate which ones represent your (`node.getData().posX`, `node.getData().posY`). All you have to do is return a `IPosition` that requires 2 basic properties: `x` and `y` (`{ x: node.getData().posX, y: node.getData().posY }`). +### Property `layout` + +If you want to use one of the predefined layouts (hierarchical (tree), grid, circular...) you can specify +the optional property `layout`. Simulation physics are ignored when a layout is applied. There is no layout +applied by default. + +#### Property `type` + +You can specify the desired layout using the `type` property that can have one of the following values: + +- `hierarchical` - a tree-like layout style that tries to portrait graph nodes in a hierarchy + +- `circular` - arranges the nodes of the graph in a circle + +- `grid` - a layout where nodes are aligned in rows and columns + +#### Property `options` + +Each layout type has its own option list you can tweak in order to change the layout behaviour. + +- `hierarchical` + - `orientation` - The tree orientation that could be whether `vertical` (by default) or `horizontal` + - `reversed` - Whether the orientation is reversed. Default orientation is top-bottom for vertical and + left-right for horizontal which is reversed when this property is set to `true`. Disabled by default `false` + +- `circular` + - `radius` - Radius of the circle in relativa units. + ### Property `render` Optional property `render` has several rendering options that you can tweak. Read more about them diff --git a/examples/example-hierarchical-layout.html b/examples/example-hierarchical-layout.html new file mode 100644 index 0000000..dcc9312 --- /dev/null +++ b/examples/example-hierarchical-layout.html @@ -0,0 +1,94 @@ + + + + + + Orb | Simple hierarchical graph + + + + +
+

Example 7 - Hierarchical layout

+

Renders a simple graph with hierarchical layout.

+ + +
+
+ + + diff --git a/examples/index.html b/examples/index.html index 98f29f1..73edf37 100644 --- a/examples/index.html +++ b/examples/index.html @@ -71,5 +71,11 @@

Orb Examples

unselected color to blue on node click event.

+
+

  • Example 8 - Hierarchical layout
  • +

    + Renders a simple graph with hierarchical layout (tree-like). +

    +
    From 57c9ddf109a68035ee5fad1b7b52ca2c73f793ba Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 15 May 2025 10:44:19 +0200 Subject: [PATCH 05/15] Fix: Naming typo --- examples/example-hierarchical-layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-hierarchical-layout.html b/examples/example-hierarchical-layout.html index dcc9312..0a03d09 100644 --- a/examples/example-hierarchical-layout.html +++ b/examples/example-hierarchical-layout.html @@ -14,7 +14,7 @@
    -

    Example 7 - Hierarchical layout

    +

    Example 8 - Hierarchical layout

    Renders a simple graph with hierarchical layout.