Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/view-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions examples/example-hierarchical-layout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang='en' type="module">
<head>
<base href=".">
<meta charset='UTF-8'>
<title>Orb | Simple hierarchical graph</title>
<script type="text/javascript" src="./orb.js"></script>
</head>
<style>
html, body {
height: 100%;
margin: 0;
}
</style>
<body>
<div style="display: flex; flex-direction: column; align-items: center; height: 100%;">
<h1>Example 8 - Hierarchical layout</h1>
<p style="width: 70%">Renders a simple graph with hierarchical layout.</p>

<!--
Make sure that your graph container has a defined width and height.
Orb will expand to any available space, but won't be visible if it's parent container is collapsed.
-->
<div id='graph' style="flex: 1; width: 100%;"></div>
</div>
<script type="text/javascript">
const container = document.getElementById('graph');

const nodes = [
{ id: 0, label: 'Root' },
{ id: 1, label: 'Child A' },
{ id: 2, label: 'Child B' },
{ id: 3, label: 'A1' },
{ id: 4, label: 'A2' },
{ id: 5, label: 'B1' },
{ id: 6, label: 'B2' },
];

const edges = [
{ id: 0, start: 0, end: 1, label: 'Root -> A' },
{ id: 1, start: 0, end: 2, label: 'Root -> B' },
{ id: 2, start: 1, end: 3, label: 'A -> A1' },
{ id: 3, start: 1, end: 4, label: 'A -> A2' },
{ id: 4, start: 2, end: 5, label: 'B -> B1' },
{ id: 5, start: 2, end: 6, label: 'B -> B2' },
];

const orb = new Orb.OrbView(container, {
layout: {
type: 'hierarchical',
options: {
'orientation': 'vertical',
'reversed': false
},
}
});

// Assign a basic style
orb.data.setDefaultStyle({
getNodeStyle(node) {
return {
borderColor: '#1d1d1d',
borderWidth: 0.6,
color: '#DD2222',
colorHover: '#e7644e',
colorSelected: '#e7644e',
fontSize: 10,
label: node.getData().label,
size: 6,
};
},
getEdgeStyle(edge) {
return {
color: '#999999',
colorHover: '#1d1d1d',
colorSelected: '#1d1d1d',
fontSize: 3,
width: 1,
widthHover: 0.9,
widthSelected: 0.9,
label: edge.getData().label,
};
},
});

// Initialize nodes and edges
orb.data.setup({ nodes, edges });

orb.render(() => {
orb.recenter();
});
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,11 @@ <h1>Orb Examples</h1>
unselected color to blue on node click event.
</p>
</div>
<div>
<a href="./example-hierarchical-layout.html"><h3><li>Example 8 - Hierarchical layout</li></h3></a>
<p>
Renders a simple graph with hierarchical layout (tree-like).
</p>
</div>
</body>
</html>
21 changes: 21 additions & 0 deletions src/models/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<N extends INodeBase, E extends IEdgeBase> {
nodes: N[];
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface IGraph<N extends INodeBase, E extends IEdgeBase> extends ISubje
getNearestNode(point: IPosition): INode<N, E> | undefined;
getNearestEdge(point: IPosition, minDistance?: number): IEdge<N, E> | undefined;
setSettings(settings: Partial<IGraphSettings<N, E>>): void;
setLayout(layout: ILayout<N, E> | undefined): void;
}

export interface IGraphSettings<N extends INodeBase, E extends IEdgeBase> {
Expand All @@ -73,6 +75,7 @@ export class Graph<N extends INodeBase, E extends IEdgeBase> extends Subject imp
});
private _defaultStyle?: Partial<IGraphStyle<N, E>>;
private _settings: IGraphSettings<N, E>;
private _layout: ILayout<N, E> | undefined;

constructor(data?: Partial<IGraphData<N, E>>, settings?: Partial<IGraphSettings<N, E>>) {
// TODO(dlozic): How to use object assign here? If I add add and export a default const here, it needs N, E.
Expand All @@ -92,6 +95,11 @@ export class Graph<N extends INodeBase, E extends IEdgeBase> extends Subject imp
this.notifyListeners();
}

setLayout(layout: ILayout<N, E> | undefined): void {
this._layout = layout;
this._resetLayout();
}

/**
* Returns a list of nodes.
*
Expand Down Expand Up @@ -274,6 +282,7 @@ export class Graph<N extends INodeBase, E extends IEdgeBase> extends Subject imp

this._applyEdgeOffsets();
this._applyStyle();
this._resetLayout();

this._settings?.onMergeData?.(data);
}
Expand All @@ -287,6 +296,7 @@ export class Graph<N extends INodeBase, E extends IEdgeBase> extends Subject imp

this._applyEdgeOffsets();
this._applyStyle();
this._resetLayout();

if (this._settings && this._settings.onRemoveData) {
const removedData: IGraphObjectsIds = {
Expand Down Expand Up @@ -603,6 +613,17 @@ export class Graph<N extends INodeBase, E extends IEdgeBase> extends Subject imp
return { nodeIds: [], edgeIds: removedEdgeIds };
}

private _resetLayout(): void {
if (!this._layout) {
this.clearPositions();
return;
}

const positions = this._layout.getPositions(this.getNodes());
this.setNodePositions(positions);
this.notifyListeners();
}

private _applyEdgeOffsets() {
const graphEdges = this.getEdges();
const edgeOffsets = getEdgeOffsets<N, E>(graphEdges);
Expand Down
20 changes: 18 additions & 2 deletions src/simulator/engine/d3-simulator-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const DEFAULT_LINK_DISTANCE = 50;

export enum D3SimulatorEngineEventType {
SIMULATION_START = 'simulation-start',
SIMULATION_STOP = 'simulation-stop',
SIMULATION_PROGRESS = 'simulation-progress',
SIMULATION_END = 'simulation-end',
SIMULATION_TICK = 'simulation-tick',
Expand Down Expand Up @@ -282,10 +283,18 @@ export class D3SimulatorEngine extends Emitter<D3SimulatorEvents> {
* This does not count as "stabilization" and won't emit any progress.
*/
activateSimulation() {
this.unfixNodes(); // If physics is disabled, the nodes get fixed in the callback from the initial setup (`simulation.on('end', () => {})`).
if (this._settings.isPhysicsEnabled) {
this.unfixNodes(); // If physics is disabled, the nodes get fixed in the callback from the initial setup (`simulation.on('end', () => {})`).
} else {
this.fixNodes();
}
this._simulation.alpha(this._settings.alpha.alpha).alphaTarget(this._settings.alpha.alphaTarget).restart();
}

stopSimulation() {
this._simulation.stop();
}

setupData(data: ISimulationGraph) {
this.clearData();

Expand All @@ -300,6 +309,10 @@ export class D3SimulatorEngine extends Emitter<D3SimulatorEvents> {
mergeData(data: Partial<ISimulationGraph>) {
this._initializeNewData(data);

if (!this._settings.isPhysicsEnabled) {
this.fixNodes();
}

if (this._settings.isSimulatingOnDataUpdate) {
this._updateSimulationData();
this.activateSimulation();
Expand Down Expand Up @@ -394,7 +407,10 @@ export class D3SimulatorEngine extends Emitter<D3SimulatorEvents> {
const edgeIds = new Set(data.edgeIds);
this._edges = this._edges.filter((edge) => !edgeIds.has(edge.id));
this._setNodeIndexByNodeId();
this._updateSimulationData();
if (this._settings.isSimulatingOnDataUpdate) {
this._updateSimulationData();
this.activateSimulation();
}
}

/**
Expand Down
52 changes: 33 additions & 19 deletions src/simulator/layout/layout.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import { IEdgeBase } from '../../models/edge';
import { INode, INodeBase, INodePosition } from '../../models/node';
import { CircleLayout } from './layouts/circle';
import { CircularLayout, ICircularLayoutOptions } from './layouts/circular';
import { IForceLayoutOptions } from './layouts/force';
import { GridLayout, IGridLayoutOptions } from './layouts/grid';
import { HierarchicalLayout, IHierarchicalLayoutOptions } from './layouts/hierarchical';

export enum layouts {
DEFAULT = 'default',
CIRCLE = 'circle',
export type LayoutType = 'circular' | 'force' | 'grid' | 'hierarchical';

export type LayoutSettingsMap = {
circular: ICircularLayoutOptions;
force: IForceLayoutOptions;
grid: IGridLayoutOptions;
hierarchical: IHierarchicalLayoutOptions;
};

export interface ILayoutSettings {
type: LayoutType;
options?: LayoutSettingsMap[LayoutType];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool stuff. Do you get an error if you add a new LayoutType that is not referenced in LayoutSettingsMap?

Also, what we talked about: Adding force layout here along with all params (gaps, etc.)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! I am now adding a force layout, and I do get an error that there is no force layout in the LayoutSettingsMap, so it is future-proof for adding new ones :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we have force simulation parameters in SimulatorEngineSettings (e.g. centering, link distance) - do we want to move them to layout settings?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make sense, but not required

}

export interface ILayout<N extends INodeBase, E extends IEdgeBase> {
getPositions(nodes: INode<N, E>[], width: number, height: number): INodePosition[];
getPositions(nodes: INode<N, E>[]): INodePosition[];
}

export class Layout<N extends INodeBase, E extends IEdgeBase> implements ILayout<N, E> {
private readonly _layout: ILayout<N, E> | null;

private layoutByLayoutName: Record<string, ILayout<N, E> | null> = {
[layouts.DEFAULT]: null,
[layouts.CIRCLE]: new CircleLayout(),
};

constructor(layoutName: string) {
this._layout = this.layoutByLayoutName[layoutName];
}

getPositions(nodes: INode<N, E>[], width: number, height: number): INodePosition[] {
return this._layout === null ? [] : this._layout.getPositions(nodes, width, height);
export class LayoutFactory {
static create<N extends INodeBase, E extends IEdgeBase>(
settings?: Partial<ILayoutSettings>,
): ILayout<N, E> | undefined {
switch (settings?.type) {
case 'circular':
return new CircularLayout<N, E>(settings.options as ICircularLayoutOptions);
case 'force':
return undefined;
case 'grid':
return new GridLayout<N, E>(settings.options as IGridLayoutOptions);
case 'hierarchical':
return new HierarchicalLayout<N, E>(settings.options as IHierarchicalLayoutOptions);
default:
throw new Error('Incorrect layout type.');
}
}
}
11 changes: 0 additions & 11 deletions src/simulator/layout/layouts/circle.ts

This file was deleted.

37 changes: 37 additions & 0 deletions src/simulator/layout/layouts/circular.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { IEdgeBase } from '../../../models/edge';
import { INode, INodeBase, INodePosition } from '../../../models/node';
import { ILayout } from '../layout';

export interface ICircularLayoutOptions {
radius?: number;
centerX?: number;
centerY?: number;
}

export const DEFAULT_CIRCULAR_LAYOUT_OPTIONS: Required<ICircularLayoutOptions> = {
radius: 100,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this maybe midRadius? Because if you have radius 10, but there are 1000000 nodes. The nodes will overlap.

centerX: 0,
centerY: 0,
};

export class CircularLayout<N extends INodeBase, E extends IEdgeBase> implements ILayout<N, E> {
private _config: Required<ICircularLayoutOptions>;

constructor(options?: ICircularLayoutOptions) {
this._config = { ...DEFAULT_CIRCULAR_LAYOUT_OPTIONS, ...options };
}

getPositions(nodes: INode<N, E>[]): INodePosition[] {
const angleStep = (2 * Math.PI) / nodes.length;

const positions = nodes.map((node, index) => {
return {
id: node.id,
x: this._config.centerX + this._config.radius * Math.cos(angleStep * index),
y: this._config.centerY + this._config.radius * Math.sin(angleStep * index),
};
});

return positions;
}
}
Loading