Skip to content
Open
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
55 changes: 37 additions & 18 deletions docs/modules/graph-layers/api-reference/layouts/graph-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export class MyLayout extends GraphLayout {
// initialize the layout
constructor(options) {}
// first time to pass the graph data into this layout
initializeGraph(graph) {}
initializeGraph(graph) {
this._syncGraph(graph);
}
// update the existing graph
updateGraph(graph) {}
protected updateGraph(graph) {}
// start the layout calculation
start() {}
// update the layout calculation
Expand Down Expand Up @@ -48,10 +50,17 @@ A layout goes through the following phases:
- Mounting:
`constructor` => `initializeGraph` => `start`
- Updating:
`updateGraph` => `update`
`_syncGraph` (internal) => `update`

There are a few events that should be triggered when the layout changes:

- `this.setProps(partialProps)`
Calling `setProps` merges the partial update into the layout configuration. If any
of the supplied properties require the layout to be recomputed, the base class
emits an `onLayoutInvalidated` event. `GraphEngine` listens to this signal and
automatically reruns `_syncGraph` + `update` so the layout can refresh itself
without additional wiring.

- `this._onLayoutStart()`
When the layout starts, `onLayoutStart` should be triggered to notify GraphGL/User. Some users might also want to leverage this event hook to perform different interactions, ex: show a spinner on the UI to indicate a new layout is computing.

Expand All @@ -75,16 +84,16 @@ startDragging => lockNodePosition => release => unlockNodePosition => resume
### Update the graph data

GraphGL will call `initializeGraph` to pass the graph data into the layout.
If the graph is the same one but part ofthe data is changed, GraphGL will call `updateGraph` method to notify the layout.
If the graph is the same one but part of the data is changed, GraphGL will notify the layout by invoking the internal `_syncGraph` helper, which in turn calls your layout's protected `updateGraph` implementation.

In this case, we can just simply update the `this._nodePositionMap` by going through all nodes in the graph.

```js
initializeGraph(graph) {
this.updateGraph(graph);
this._syncGraph(graph);
}

updateGraph(grpah) {
protected updateGraph(graph) {
this._graph = graph;
this._nodePositionMap = graph.getNodes().reduce((res, node) => {
res[node.getId()] = this._nodePositionMap[node.getId()] || [0, 0];
Expand All @@ -93,6 +102,8 @@ In this case, we can just simply update the `this._nodePositionMap` by going thr
}
```

Because `updateGraph` is now protected, application code should rely on `setProps` (or the `GraphEngine` orchestration) instead of calling it directly when graph data changes.

### Compute layout

GraphGL will call `start()` of the layout to kick start the layout calculation.
Expand Down Expand Up @@ -146,25 +157,32 @@ export default class RandomLayout extends GraphLayout {
static defaultProps = {
viewportWidth: 1000,
viewportHeight: 1000
};
} as const;

constructor(options) {
// init GraphLayout
super(options);
super(options, RandomLayout.defaultProps);
// give a name to this layout
this._name = 'RandomLayout';
// combine the default options with user input
this.props = {
...this.defaultProps,
...options
};
// a map to persis the position of nodes.
this._nodePositionMap = {};
}
}
```


### `setProps(partialProps)`

Use `setProps` to update a layout's configuration after construction. The base
class merges the supplied partial object with the existing props (and defaults
if provided) and returns `{changed, changedKeys, needsRecompute}`. When
`needsRecompute` is `true`, an `onLayoutInvalidated` event is dispatched. The
`GraphEngine` listens for this event to run `_syncGraph` and `update`
automatically. If you are using a layout outside of `GraphEngine`, call
`setProps`, then manually trigger any additional work by invoking your
protected `updateGraph` implementation (e.g. through a custom helper) and `update` as needed.


### Getters

GraphGL will keep retrieving the position of nodes and edges from the layout. You will need to provide two getters `getNodePosition` and `getEdgePosition`.
Expand Down Expand Up @@ -197,13 +215,14 @@ getEdgePosition = (edge) => {
import {GraphLayout} from '@deck.gl-community/graph-layers';

export default class RandomLayout extends GraphLayout {
static defaultProps = {
viewportWidth: 1000,
viewportHeight: 1000
};

constructor(options) {
super(options);
super(options, RandomLayout.defaultProps);
this._name = 'RandomLayout';
this.props = {
...defaultProps,
...options
};
this._nodePositionMap = {};
}

Expand Down
10 changes: 9 additions & 1 deletion modules/graph-layers/src/core/graph-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export class GraphEngine extends EventTarget {
this._graph.addEventListener('onEdgeAdded', this._onGraphStructureChanged);
this._graph.addEventListener('onEdgeRemoved', this._onGraphStructureChanged);

this._layout.addEventListener('onLayoutInvalidated', this._onLayoutInvalidated);
this._layout.addEventListener('onLayoutStart', this._onLayoutStart);
this._layout.addEventListener('onLayoutChange', this._onLayoutChange);
this._layout.addEventListener('onLayoutDone', this._onLayoutDone);
Expand All @@ -176,6 +177,7 @@ export class GraphEngine extends EventTarget {
this._graph.removeEventListener('onEdgeAdded', this._onGraphStructureChanged);
this._graph.removeEventListener('onEdgeRemoved', this._onGraphStructureChanged);

this._layout.removeEventListener('onLayoutInvalidated', this._onLayoutInvalidated);
this._layout.removeEventListener('onLayoutStart', this._onLayoutStart);
this._layout.removeEventListener('onLayoutChange', this._onLayoutChange);
this._layout.removeEventListener('onLayoutDone', this._onLayoutDone);
Expand All @@ -192,9 +194,15 @@ export class GraphEngine extends EventTarget {
}
};

_onLayoutInvalidated = () => {
log.log(0, 'GraphEngine: layout invalidated')();
this._layoutDirty = true;
this._graphChanged();
};

_updateLayout = () => {
log.log(0, 'GraphEngine: layout update');
this._layout.updateGraph(this._graph);
this._layout._syncGraph(this._graph);
this._layout.update();
this._layoutDirty = false;
};
Expand Down
101 changes: 96 additions & 5 deletions modules/graph-layers/src/core/graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ export type GraphLayoutEventDetail = {

export type GraphLayoutProps = {};

export type GraphLayoutInvalidationDetail<
PropsT extends GraphLayoutProps = GraphLayoutProps
> = {
changedKeys: (keyof PropsT)[];
};

export type GraphLayoutPropsUpdateResult<
PropsT extends GraphLayoutProps = GraphLayoutProps
> = {
changed: boolean;
changedKeys: (keyof PropsT)[];
needsRecompute: boolean;
};

/** All the layout classes are extended from this base layout class. */
export abstract class GraphLayout<
PropsT extends GraphLayoutProps = GraphLayoutProps
Expand All @@ -28,6 +42,7 @@ export abstract class GraphLayout<
protected readonly _name: string = 'GraphLayout';
/** Extra configuration props of the layout. */
protected props: Required<PropsT>;
protected readonly _defaultProps: Readonly<Partial<PropsT>>;

/**
* Last computed layout bounds in local layout coordinates.
Expand All @@ -44,9 +59,48 @@ export abstract class GraphLayout<
* Constructor of GraphLayout
* @param props extra configuration props of the layout
*/
constructor(props: GraphLayoutProps, defaultProps?: Required<PropsT>) {
constructor(props: PropsT, defaultProps?: Readonly<Partial<PropsT>>) {
super();
this.props = {...defaultProps, ...props};
this._defaultProps = (defaultProps ?? {}) as Readonly<Partial<PropsT>>;
const mergedProps = this._mergeProps(props);
this._validateProps(mergedProps);
this.props = mergedProps;
}

/**
* Merge new props into the layout configuration.
* @param partial Partial props to merge into the layout.
* @returns Update result containing change metadata.
*/
setProps(partial: Partial<PropsT>): GraphLayoutPropsUpdateResult<PropsT> {
if (!partial || !Object.keys(partial).length) {
return {changed: false, changedKeys: [], needsRecompute: false};
}

const previousProps = this.props;
const nextProps = this._mergeProps(partial, previousProps);

this._validateProps(nextProps, partial);

const changedKeys: (keyof PropsT)[] = [];
for (const key of Object.keys(partial) as (keyof PropsT)[]) {
if (!Object.is(previousProps[key], nextProps[key])) {
changedKeys.push(key);
}
}

if (!changedKeys.length) {
return {changed: false, changedKeys, needsRecompute: false};
}

const needsRecompute = this._shouldRecompute(previousProps, nextProps, changedKeys);
this.props = nextProps;

if (needsRecompute) {
this._invalidateLayout(changedKeys);
}

return {changed: true, changedKeys, needsRecompute};
}

/**
Expand Down Expand Up @@ -97,12 +151,12 @@ export abstract class GraphLayout<
return this._bounds;
}

/** virtual functions: will be implemented in the child class */
/** virtual functions: will be implemented in the child class */

/** first time to pass the graph data into this layout */
abstract initializeGraph(graph: Graph);
abstract initializeGraph(graph: Graph): void;
/** update the existing graph */
abstract updateGraph(graph: Graph);
protected abstract updateGraph(graph: Graph): void;
/** start the layout calculation */
abstract start();
/** update the layout calculation */
Expand All @@ -112,9 +166,46 @@ export abstract class GraphLayout<
/** stop the layout calculation */
abstract stop();

/** @internal */
_syncGraph(graph: Graph): void {
this.updateGraph(graph);
}


// INTERNAL METHODS

protected _mergeProps(
partial: Partial<PropsT>,
base: Readonly<Required<PropsT>> | null = null
): Required<PropsT> {
return {
...(this._defaultProps as Required<PropsT>),
...(base ?? this.props ?? ({} as Required<PropsT>)),
...partial
} as Required<PropsT>;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _validateProps(
props: Readonly<Required<PropsT>>,
_partial?: Partial<PropsT>
): void {}

protected _shouldRecompute(
_previous: Readonly<Required<PropsT>>,
_next: Readonly<Required<PropsT>>,
changedKeys: (keyof PropsT)[]
): boolean {
return changedKeys.length > 0;
}

protected _invalidateLayout(changedKeys: (keyof PropsT)[]): void {
const detail: GraphLayoutInvalidationDetail<PropsT> = {changedKeys: [...changedKeys]};
this.dispatchEvent(
new CustomEvent<GraphLayoutInvalidationDetail<PropsT>>('onLayoutInvalidated', {detail})
);
}

/** Hook for subclasses to update bounds prior to emitting events. */
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected _updateBounds(): void {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,6 @@ export class CollapsableD3DagLayout extends D3DagLayout<CollapsableD3DagLayoutPr
super(props, CollapsableD3DagLayout.defaultProps);
}

override setProps(props: Partial<CollapsableD3DagLayoutProps>): void {
super.setProps(props);
if (props.collapseLinearChains !== undefined && this._graph) {
this._runLayout();
}
}

override updateGraph(graph: Graph): void {
super.updateGraph(graph);
this._chainDescriptors.clear();
this._nodeToChainId.clear();
this._hiddenNodeIds.clear();
}

override toggleCollapsedChain(chainId: string): void {
if (!this._graph) {
log.log(1, `CollapsableD3DagLayout: toggleCollapsedChain(${chainId}) ignored (no graph)`);
Expand Down Expand Up @@ -115,6 +101,13 @@ export class CollapsableD3DagLayout extends D3DagLayout<CollapsableD3DagLayoutPr
}
}

protected override updateGraph(graph: Graph): void {
super.updateGraph(graph);
this._chainDescriptors.clear();
this._nodeToChainId.clear();
this._hiddenNodeIds.clear();
}

protected override _refreshCollapsedChains(): void {
const previousChainCount = this._chainDescriptors.size;

Expand Down
Loading