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..0a03d09 --- /dev/null +++ b/examples/example-hierarchical-layout.html @@ -0,0 +1,94 @@ + + + + + + Orb | Simple hierarchical graph + + + + +
+

Example 8 - 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). +

    +
    diff --git a/src/models/graph.ts b/src/models/graph.ts index 8b7801e..7bcf0e0 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 | undefined): 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,11 @@ export class Graph extends Subject imp this.notifyListeners(); } + setLayout(layout: ILayout | undefined): void { + this._layout = layout; + this._resetLayout(); + } + /** * Returns a list of nodes. * @@ -274,6 +282,7 @@ export class Graph extends Subject imp this._applyEdgeOffsets(); this._applyStyle(); + this._resetLayout(); this._settings?.onMergeData?.(data); } @@ -287,6 +296,7 @@ export class Graph extends Subject imp this._applyEdgeOffsets(); this._applyStyle(); + this._resetLayout(); if (this._settings && this._settings.onRemoveData) { const removedData: IGraphObjectsIds = { @@ -603,6 +613,17 @@ export class Graph 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(graphEdges); diff --git a/src/simulator/engine/d3-simulator-engine.ts b/src/simulator/engine/d3-simulator-engine.ts index 225fd67..f20346f 100644 --- a/src/simulator/engine/d3-simulator-engine.ts +++ b/src/simulator/engine/d3-simulator-engine.ts @@ -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', @@ -282,10 +283,18 @@ export class D3SimulatorEngine extends Emitter { * 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(); @@ -300,6 +309,10 @@ export class D3SimulatorEngine extends Emitter { mergeData(data: Partial) { this._initializeNewData(data); + if (!this._settings.isPhysicsEnabled) { + this.fixNodes(); + } + if (this._settings.isSimulatingOnDataUpdate) { this._updateSimulationData(); this.activateSimulation(); @@ -394,7 +407,10 @@ export class D3SimulatorEngine extends Emitter { 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(); + } } /** diff --git a/src/simulator/layout/layout.ts b/src/simulator/layout/layout.ts index defe2bc..e666e4f 100644 --- a/src/simulator/layout/layout.ts +++ b/src/simulator/layout/layout.ts @@ -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]; } 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); +export class LayoutFactory { + static create( + settings?: Partial, + ): ILayout | undefined { + switch (settings?.type) { + case 'circular': + return new CircularLayout(settings.options as ICircularLayoutOptions); + case 'force': + return undefined; + case 'grid': + return new GridLayout(settings.options as IGridLayoutOptions); + case 'hierarchical': + return new HierarchicalLayout(settings.options as IHierarchicalLayoutOptions); + default: + throw new Error('Incorrect layout type.'); + } } } 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..693d9a0 --- /dev/null +++ b/src/simulator/layout/layouts/circular.ts @@ -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 = { + radius: 100, + centerX: 0, + centerY: 0, +}; + +export class CircularLayout implements ILayout { + private _config: Required; + + constructor(options?: ICircularLayoutOptions) { + this._config = { ...DEFAULT_CIRCULAR_LAYOUT_OPTIONS, ...options }; + } + + getPositions(nodes: INode[]): 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; + } +} diff --git a/src/simulator/layout/layouts/force.ts b/src/simulator/layout/layouts/force.ts new file mode 100644 index 0000000..29edc22 --- /dev/null +++ b/src/simulator/layout/layouts/force.ts @@ -0,0 +1,11 @@ +export interface IForceLayoutOptions { + centerX?: number; + centerY?: number; + nodeDistance?: number; +} + +export const DEFAULT_FORCE_LAYOUT_OPTIONS: Required = { + centerX: 0, + centerY: 0, + nodeDistance: 50, +}; diff --git a/src/simulator/layout/layouts/grid.ts b/src/simulator/layout/layouts/grid.ts new file mode 100644 index 0000000..e48470b --- /dev/null +++ b/src/simulator/layout/layouts/grid.ts @@ -0,0 +1,38 @@ +import { IEdgeBase } from '../../../models/edge'; +import { INode, INodeBase, INodePosition } from '../../../models/node'; +import { ILayout } from '../layout'; + +export interface IGridLayoutOptions { + rowGap?: number; + colGap?: number; +} + +export const DEFAULT_GRID_LAYOUT_OPTIONS: Required = { + rowGap: 50, + colGap: 50, +}; + +export class GridLayout implements ILayout { + private _config: Required; + + constructor(options?: IGridLayoutOptions) { + this._config = { ...DEFAULT_GRID_LAYOUT_OPTIONS, ...options }; + } + + getPositions(nodes: INode[]): INodePosition[] { + const rows = Math.ceil(Math.sqrt(nodes.length)); + const cols = Math.ceil(nodes.length / rows); + + const positions: INodePosition[] = []; + + for (let i = 0; i < nodes.length; i++) { + const row = Math.floor(i / cols); + const col = i % cols; + const x = col * this._config.colGap; + const y = row * this._config.rowGap; + positions.push({ id: nodes[i].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..777c1f1 --- /dev/null +++ b/src/simulator/layout/layouts/hierarchical.ts @@ -0,0 +1,163 @@ +import { IEdge, IEdgeBase } from '../../../models/edge'; +import { INode, INodeBase, INodePosition } from '../../../models/node'; +import { ILayout } from '../layout'; + +export type HierarchicalLayoutOrientation = 'horizontal' | 'vertical'; + +export interface IHierarchicalLayoutOptions { + nodeGap?: number; + levelGap?: number; + treeGap?: number; + orientation?: HierarchicalLayoutOrientation; + reversed?: boolean; +} + +export const DEFAULT_HIERARCHICAL_LAYOUT_OPTIONS: Required = { + nodeGap: 50, + levelGap: 50, + treeGap: 100, + orientation: 'vertical', + reversed: false, +}; + +export class HierarchicalLayout implements ILayout { + private _config: Required; + + constructor(options?: IHierarchicalLayoutOptions) { + this._config = { ...DEFAULT_HIERARCHICAL_LAYOUT_OPTIONS, ...options }; + } + + getPositions(nodes: INode[]): INodePosition[] { + const components = this.getConnectedComponents(nodes); + const positions: INodePosition[] = new Array(nodes.length); + let maxX = 0; + let maxHeight = 0; + let counter = 0; + + for (let i = 0; i < components.length; i++) { + const levels: Map[]> = this.assignLevels(components[i]); + const maxLevelSize = Math.max(...Array.from(levels.values()).map((levelNodes) => levelNodes.length)); + + if (levels.size * this._config.levelGap > maxHeight) { + maxHeight = levels.size * this._config.levelGap; + } + + let offsetX = i === 0 ? 0 : this._config.treeGap + maxX; + + if (i > 0) { + offsetX += ((maxLevelSize - 1) * this._config.nodeGap) / 2; + } + + for (let j = 0; j < levels.size; j++) { + const y = j * this._config.levelGap; + const level = levels.get(j); + if (!level) { + continue; + } + + const width = level.length * this._config.nodeGap; + + for (let k = 0; k < level.length; k++) { + const node = level[k]; + const x = width / 2 - k * this._config.nodeGap + offsetX; + if (x > maxX) { + maxX = x; + } + + positions[counter++] = { + id: node.getId(), + x: this._config.orientation === 'horizontal' ? y : x, + y: this._config.orientation === 'horizontal' ? x : y, + }; + } + } + } + + if (this._config.reversed === true) { + positions.forEach((position) => { + if (this._config.orientation === 'horizontal' && position.x !== undefined) { + position.x = maxX - position.x; + } + if (this._config.orientation === 'vertical' && position.y !== undefined) { + position.y = maxHeight - position.y; + } + }); + } + + return positions; + } + + getConnectedComponents = (nodes: INode[]): INode[][] => { + const visited = new Set>(); + const components: INode[][] = []; + + for (let i = 0; i < nodes.length; i++) { + if (visited.has(nodes[i])) { + continue; + } + + const component: INode[] = []; + const queue: INode[] = [nodes[i]]; + visited.add(nodes[i]); + + while (queue.length > 0) { + const current = queue.pop(); + + if (current) { + component.push(current); + const neighbors = current.getAdjacentNodes(); + for (let j = 0; j < neighbors.length; j++) { + if (visited.has(neighbors[j])) { + continue; + } + + visited.add(neighbors[j]); + queue.push(neighbors[j]); + } + } + } + + components.push(component); + } + + return components; + }; + + assignLevels = (nodes: INode[]): Map[]> => { + const levels = new Map[]>(); + const visited = new Set>(); + + let root = nodes.filter((node) => this.getExternalInEdges(node).length === 0)[0]; + + if (!root) { + root = nodes.sort((a, b) => this.getExternalInEdges(a).length - this.getExternalInEdges(b).length)[0]; + } + + const queue: [INode, number][] = [[root, 0]]; + + for (const [node, level] of queue) { + if (visited.has(node)) { + continue; + } + + visited.add(node); + if (levels.has(level)) { + levels.get(level)?.push(node); + } else { + levels.set(level, [node]); + } + + const neighbors = node.getAdjacentNodes(); + + for (let i = 0; i < neighbors.length; i++) { + queue.push([neighbors[i], level + 1]); + } + } + + return levels; + }; + + getExternalInEdges = (node: INode): IEdge[] => { + return node.getInEdges().filter((edge) => edge.startNode.id !== edge.endNode.id); + }; +} diff --git a/src/simulator/shared.ts b/src/simulator/shared.ts index 7501367..b1190de 100644 --- a/src/simulator/shared.ts +++ b/src/simulator/shared.ts @@ -64,6 +64,7 @@ export interface ISimulator extends IEmitter { // Simulation handlers simulate(): void; activateSimulation(): void; + stopSimulation(): void; // Node handlers startDragNode(): void; diff --git a/src/simulator/types/main-thread-simulator.ts b/src/simulator/types/main-thread-simulator.ts index 1bbc36c..b560b33 100644 --- a/src/simulator/types/main-thread-simulator.ts +++ b/src/simulator/types/main-thread-simulator.ts @@ -73,6 +73,10 @@ export class MainThreadSimulator extends Emitter implements ISi this._simulator.activateSimulation(); } + stopSimulation() { + this._simulator.stopSimulation(); + } + startDragNode() { this._simulator.startDragNode(); } diff --git a/src/simulator/types/web-worker-simulator/message/worker-input.ts b/src/simulator/types/web-worker-simulator/message/worker-input.ts index 5d28a74..c252d4a 100644 --- a/src/simulator/types/web-worker-simulator/message/worker-input.ts +++ b/src/simulator/types/web-worker-simulator/message/worker-input.ts @@ -19,6 +19,7 @@ export enum WorkerInputType { Simulate = 'Simulate', ActivateSimulation = 'Activate Simulation', UpdateSimulation = 'Update Simulation', + StopSimulation = 'Stop Simulation', // Node dragging message types StartDragNode = 'Start Drag Node', @@ -77,6 +78,8 @@ type IWorkerInputSimulatePayload = IWorkerPayload; type IWorkerInputActivateSimulationPayload = IWorkerPayload; +type IWorkerInputStopSimulationPayload = IWorkerPayload; + type IWorkerInputUpdateSimulationPayload = IWorkerPayload< WorkerInputType.UpdateSimulation, { @@ -121,6 +124,7 @@ export type IWorkerInputPayload = | IWorkerInputClearDataPayload | IWorkerInputSimulatePayload | IWorkerInputActivateSimulationPayload + | IWorkerInputStopSimulationPayload | IWorkerInputUpdateSimulationPayload | IWorkerInputStartDragNodePayload | IWorkerInputDragNodePayload diff --git a/src/simulator/types/web-worker-simulator/simulator.worker.ts b/src/simulator/types/web-worker-simulator/simulator.worker.ts index d0d76a2..f07da1d 100644 --- a/src/simulator/types/web-worker-simulator/simulator.worker.ts +++ b/src/simulator/types/web-worker-simulator/simulator.worker.ts @@ -41,6 +41,11 @@ addEventListener('message', ({ data }: MessageEvent) => { break; } + case WorkerInputType.StopSimulation: { + simulator.stopSimulation(); + break; + } + case WorkerInputType.SetupData: { simulator.setupData(data.data); break; diff --git a/src/simulator/types/web-worker-simulator/web-worker-simulator.ts b/src/simulator/types/web-worker-simulator/web-worker-simulator.ts index 02f35ea..005d043 100644 --- a/src/simulator/types/web-worker-simulator/web-worker-simulator.ts +++ b/src/simulator/types/web-worker-simulator/web-worker-simulator.ts @@ -104,6 +104,10 @@ export class WebWorkerSimulator extends Emitter implements ISim this.emitToWorker({ type: WorkerInputType.ActivateSimulation }); } + stopSimulation() { + this.emitToWorker({ type: WorkerInputType.StopSimulation }); + } + updateSimulation(nodes: ISimulationNode[], edges: ISimulationEdge[]) { this.emitToWorker({ type: WorkerInputType.UpdateSimulation, data: { nodes, edges } }); } diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 3b5711a..16ea79d 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -13,7 +13,12 @@ import { INode, INodeBase, isNode } from '../models/node'; import { IEdge, IEdgeBase, isEdge } from '../models/edge'; import { IOrbView } from './shared'; import { DefaultEventStrategy, IEventStrategy, IEventStrategySettings } from '../models/strategy'; -import { ID3SimulatorEngineSettings } from '../simulator/engine/d3-simulator-engine'; +import { + DEFAULT_SETTINGS, + ID3SimulatorEngineSettings, + ID3SimulatorEngineSettingsCentering, + ID3SimulatorEngineSettingsLinks, +} from '../simulator/engine/d3-simulator-engine'; import { copyObject } from '../utils/object.utils'; import { OrbEmitter, OrbEventType } from '../events'; import { IRenderer, RenderEventType, IRendererSettingsInit, IRendererSettings } from '../renderer/shared'; @@ -22,6 +27,8 @@ 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'; +import { DEFAULT_FORCE_LAYOUT_OPTIONS } from '../simulator/layout/layouts/force'; export interface IGraphInteractionSettings { isDragEnabled: boolean; @@ -34,6 +41,7 @@ export interface IOrbViewSettings { render: Partial; strategy: Partial; interaction: Partial; + layout: Partial; zoomFitTransitionMs: number; isOutOfBoundsDragEnabled: boolean; areCoordinatesRounded: boolean; @@ -72,6 +80,10 @@ export class OrbView implements IOrbVi isPhysicsEnabled: false, ...settings?.simulation, }, + layout: { + type: 'force', + ...settings?.layout, + }, render: { ...settings?.render, }, @@ -149,36 +161,30 @@ export class OrbView implements IOrbVi .on('dblclick.zoom', this.mouseDoubleClicked); this._simulator = SimulatorFactory.getSimulator(); - this._simulator.on(SimulatorEventType.SIMULATION_START, () => { - // this._isSimulating = true; - this._simulationStartedAt = Date.now(); - this._events.emit(OrbEventType.SIMULATION_START, undefined); - }); - this._simulator.on(SimulatorEventType.SIMULATION_PROGRESS, (data) => { - this._graph.setNodePositions(data.nodes); - this._events.emit(OrbEventType.SIMULATION_STEP, { progress: data.progress }); - this.render(); - }); - this._simulator.on(SimulatorEventType.SIMULATION_END, (data) => { - this._graph.setNodePositions(data.nodes); - this.render(); - // this._isSimulating = false; - this._onSimulationEnd?.(); - this._onSimulationEnd = undefined; - this._events.emit(OrbEventType.SIMULATION_END, { durationMs: Date.now() - this._simulationStartedAt }); - }); - this._simulator.on(SimulatorEventType.SIMULATION_STEP, (data) => { - this._graph.setNodePositions(data.nodes); - this.render(); - }); - this._simulator.on(SimulatorEventType.NODE_DRAG, (data) => { - this._graph.setNodePositions(data.nodes); - this.render(); - }); - this._simulator.on(SimulatorEventType.SETTINGS_UPDATE, (data) => { - this._settings.simulation = data.settings; - }); + if (this._settings.layout.type === 'force') { + this._enableSimulation(); + } + + if (this._settings.layout.options) { + const _options = { + ...DEFAULT_FORCE_LAYOUT_OPTIONS, + ...this._settings.layout.options, + }; + + this._settings.simulation.centering = { + ...(DEFAULT_SETTINGS.centering as Required), + ...this._settings.simulation.centering, + x: _options.centerX, + y: _options.centerY, + }; + + this._settings.simulation.links = { + ...(DEFAULT_SETTINGS.links as Required), + ...this._settings.simulation.links, + distance: _options.nodeDistance, + }; + } this._simulator.setSettings(this._settings.simulation); // TODO(dlozic): Optimize crud operations here. @@ -193,6 +199,9 @@ export class OrbView implements IOrbVi const nodePositions = this._graph.getNodePositions(); const edgePositions = this._graph.getEdgePositions(); // this._onSimulationEnd = onRendered; + if (this._settings.layout) { + this._graph.setLayout(LayoutFactory.create(this._settings.layout)); + } this._simulator.setupData({ nodes: nodePositions, edges: edgePositions }); }, onMergeData: (data) => { @@ -203,10 +212,12 @@ export class OrbView implements IOrbVi this._assignPositions(this._graph.getNodes(nodeFilter)); - const nodePositions = this._graph.getNodePositions(nodeFilter); - const edgePositions = this._graph.getEdgePositions(edgeFilter); + if (this._settings.layout.type === 'force') { + const nodePositions = this._graph.getNodePositions(nodeFilter); + const edgePositions = this._graph.getEdgePositions(edgeFilter); - this._simulator.mergeData({ nodes: nodePositions, edges: edgePositions }); + this._simulator.mergeData({ nodes: nodePositions, edges: edgePositions }); + } }, onRemoveData: (data) => { this._simulator.deleteData(data); @@ -244,6 +255,37 @@ export class OrbView implements IOrbVi this._settings.render = this._renderer.getSettings(); } + if (settings.layout) { + const shouldRecenter = this._settings.layout.type !== settings.layout.type; + this._settings.layout = { + ...this._settings.layout, + ...settings.layout, + }; + + this._graph.setLayout(LayoutFactory.create(this._settings.layout)); + + const nodePositions = this._graph.getNodePositions(); + const edgePositions = this._graph.getEdgePositions(); + + this._simulator.setupData({ nodes: nodePositions, edges: edgePositions }); + + if (this._settings.layout.type === 'force') { + this._enableSimulation(); + this._simulator.releaseNodes(); + } else { + this._disableSimulation(); + this._simulator.clearData(); + } + + if (shouldRecenter) { + this._simulator.once(SimulatorEventType.SIMULATION_END, () => { + this.recenter(); + }); + } + + this.render(); + } + if (settings.strategy) { if (isBoolean(settings.strategy.isDefaultHoverEnabled)) { this._settings.strategy.isDefaultHoverEnabled = settings.strategy.isDefaultHoverEnabled; @@ -604,6 +646,74 @@ export class OrbView implements IOrbVi this.render(); }; + private _enableSimulation = () => { + this._simulator.on(SimulatorEventType.SIMULATION_START, () => { + // this._isSimulating = true; + this._simulationStartedAt = Date.now(); + this._events.emit(OrbEventType.SIMULATION_START, undefined); + }); + this._simulator.on(SimulatorEventType.SIMULATION_PROGRESS, (data) => { + this._graph.setNodePositions(data.nodes); + this._events.emit(OrbEventType.SIMULATION_STEP, { progress: data.progress }); + this.render(); + }); + this._simulator.on(SimulatorEventType.SIMULATION_END, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + // this._isSimulating = false; + this._onSimulationEnd?.(); + this._onSimulationEnd = undefined; + this._events.emit(OrbEventType.SIMULATION_END, { durationMs: Date.now() - this._simulationStartedAt }); + }); + this._simulator.on(SimulatorEventType.SIMULATION_STEP, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + }); + this._simulator.on(SimulatorEventType.NODE_DRAG, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + }); + this._simulator.on(SimulatorEventType.SETTINGS_UPDATE, (data) => { + this._settings.simulation = data.settings; + }); + + this._simulator.activateSimulation(); + }; + + private _disableSimulation = () => { + this._simulator.off(SimulatorEventType.SIMULATION_START, () => { + // this._isSimulating = true; + this._simulationStartedAt = Date.now(); + this._events.emit(OrbEventType.SIMULATION_START, undefined); + }); + this._simulator.off(SimulatorEventType.SIMULATION_PROGRESS, (data) => { + this._graph.setNodePositions(data.nodes); + this._events.emit(OrbEventType.SIMULATION_STEP, { progress: data.progress }); + this.render(); + }); + this._simulator.off(SimulatorEventType.SIMULATION_END, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + // this._isSimulating = false; + this._onSimulationEnd?.(); + this._onSimulationEnd = undefined; + this._events.emit(OrbEventType.SIMULATION_END, { durationMs: Date.now() - this._simulationStartedAt }); + }); + this._simulator.off(SimulatorEventType.SIMULATION_STEP, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + }); + this._simulator.off(SimulatorEventType.NODE_DRAG, (data) => { + this._graph.setNodePositions(data.nodes); + this.render(); + }); + this._simulator.off(SimulatorEventType.SETTINGS_UPDATE, (data) => { + this._settings.simulation = data.settings; + }); + + this._simulator.stopSimulation(); + }; + // TODO: Do we keep these fixNodes() { this._simulator.fixNodes();