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.
+