Skip to content

Commit

Permalink
feat: Add Dagre layout component for directed acyclic graphs (DAGs) (#…
Browse files Browse the repository at this point in the history
…268)

* feat: Add Dagre layout component for directed acyclic graphs (DAGs)

* docs: Add `grid-cols-*` RAM utils (xs, sm, md, lg)

* feat(Dagre): Add nodes(), edges(), and nodeId() accessor props

* feat(CurveMenuField): Forward restProps to underlying MenuField

* docs(Dagre): Add basic docs

* fix: workaround type/check errors

* feat(TransformControls): Support setting button `size` and add container border by default

* docs(Dagre): Add transform

* fix: Incorrect edgeSeperation prop type reference

* feat(Dagre): Add edge label support.  Support "none" alignment.  Add TCP State Diagram example.

* docs: Add colors to CLOSED/ESTAB nodes

* docs(Dagre): Move settings to sidebar toggle

* feat(Dagre): Support passing `directed`, `multigraph`, and `compound` graph optoins, and specifying `parent` on node for compound graphs.  Add WIP compound/cluster graph example

* docs(Dagre): Add playground with more data examples

* docs(Dagre): Support customizing arrow in examples and improve link opacity handling

* docs(Dagre): Remove cluster example until aligns with dagre-d3 example

* fix(Spline): Improve initial data display / transition on non-cartesian charts (ex. hierarchy/graph)

* fix(Dagre): Resolve type check errors
  • Loading branch information
techniq authored Dec 13, 2024
1 parent 529c9e2 commit 8213890
Show file tree
Hide file tree
Showing 21 changed files with 3,264 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-garlics-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat: Add Dagre layout component for directed acyclic graphs (DAGs)
5 changes: 5 additions & 0 deletions .changeset/shaggy-lions-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Spline): Improve initial data display / transition on non-cartesian charts (ex. hierarchy/graph)
1 change: 1 addition & 0 deletions packages/layerchart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
},
"type": "module",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@layerstack/svelte-actions": "^0.0.9",
"@layerstack/svelte-stores": "^0.0.9",
"@layerstack/tailwind": "^0.0.11",
Expand Down
151 changes: 151 additions & 0 deletions packages/layerchart/src/lib/components/Dagre.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<script module>
export type DagreGraphData = {
nodes: Array<{ id: string; parent?: string; label?: string | dagre.Label }>;
edges: Array<{ source: string; target: string; label?: string }>;
};
export const RankDir = {
'top-bottom': 'TB',
'bottom-top': 'BT',
'left-right': 'LR',
'right-left': 'RL',
};
export const Align = {
none: undefined,
'up-left': 'UL',
'up-right': 'UR',
'down-left': 'DL',
'down-right': 'DR',
};
export const EdgeLabelPosition = {
left: 'l',
center: 'c',
right: 'r',
};
</script>

<script lang="ts">
import dagre, { type Edge, type EdgeConfig, type GraphEdge } from '@dagrejs/dagre';
/** Data of nodes and edges to build graph */
export let data: DagreGraphData;
export let nodes = (d: any) => d.nodes;
export let nodeId = (d: any) => d.id;
export let edges = (d: any) => d.edges;
/** Set graph as directed (true, default) or undirected (false), which does not treat the order of nodes in an edge as significant. */
export let directed = true;
/** Allow a graph to have multiple edges between the same pair of nodes */
export let multigraph = false;
/** Allow a graph to have compound nodes - nodes which can be the `parent` of other nodes */
export let compound = false;
/** Type of algorithm to assigns a rank to each node in the input graph */
export let ranker: 'network-simplex' | 'tight-tree' | 'longest-path' = 'network-simplex';
/** Direction for rank nodes */
export let direction: keyof typeof RankDir = 'top-bottom';
/** Alignment for rank nodes */
export let align: keyof typeof Align | undefined = undefined;
/** Number of pixels between each rank in the layout */
export let rankSeparation = 50;
/** Number of pixels that separate nodes horizontally in the layout */
export let nodeSeparation = 50;
/** Number of pixels that separate edges horizontally in the layout */
export let edgeSeparation = 10;
/** Default node width if not defined on node */
export let nodeWidth = 100;
/** Default node height if not defined on node */
export let nodeHeight = 50;
/** Default link label width if not defined on edge */
export let edgeLabelWidth = 100;
/** Default edge label height if not defined on edge */
export let edgeLabelHeight = 20;
/** Default edge label height if not defined on edge */
export let edgeLabelPosition: keyof typeof EdgeLabelPosition = 'center';
/** Default pixels to move the label away from the edge if not defined on edge. Applies only when labelpos is l or r.*/
export let edgeLabelOffset = 10;
/** Filter nodes */
export let filterNodes: (nodeId: string, graph: dagre.graphlib.Graph) => boolean = () => true;
let graph: dagre.graphlib.Graph;
$: {
let g = new dagre.graphlib.Graph({ directed, multigraph, compound });
g.setGraph({
ranker: ranker,
rankdir: RankDir[direction],
align: align ? Align[align] : undefined,
ranksep: rankSeparation,
nodesep: nodeSeparation,
edgesep: edgeSeparation,
});
g.setDefaultEdgeLabel(() => {
return {};
});
nodes(data).forEach((n: any) => {
const id = nodeId(n);
g.setNode(nodeId(n), {
id,
label: typeof n.label === 'string' ? n.label : id,
width: nodeWidth,
height: nodeHeight,
...(typeof n.label === 'object' ? n.label : null),
});
if (n.parent) {
g.setParent(id, n.parent);
}
});
edges(data).forEach((e: any) => {
const { source, target, label, ...rest } = e;
g.setEdge(
e.source,
e.target,
label
? {
label: label,
labelpos: EdgeLabelPosition[edgeLabelPosition],
labeloffset: edgeLabelOffset,
width: edgeLabelWidth,
height: edgeLabelHeight,
...rest,
}
: {}
);
});
g = filterNodes ? g.filterNodes((nodeId) => filterNodes(nodeId, graph)) : graph;
dagre.layout(g);
graph = g;
}
$: graphNodes = graph.nodes().map((id) => graph.node(id));
$: graphEdges = graph.edges().map((edge) => ({ ...edge, ...graph.edge(edge) })) as Array<
Edge & EdgeConfig & GraphEdge // `EdgeConfig` is excluded when inferred from usage
>;
</script>

<slot nodes={graphNodes} edges={graphEdges} />
2 changes: 0 additions & 2 deletions packages/layerchart/src/lib/components/Spline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,6 @@
if (curve) path.curve(curve);
return path(data ?? $contextData);
} else {
return '';
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { type ComponentProps } from 'svelte';
import { Button, Icon, MenuButton, Tooltip } from 'svelte-ux';
import { cls } from '@layerstack/tailwind';
Expand Down Expand Up @@ -28,6 +29,7 @@
export let placement: Placement = 'top-right';
export let orientation: 'horizontal' | 'vertical' = 'vertical';
export let size: ComponentProps<Button>['size'] = 'md';
type Actions = 'zoomIn' | 'zoomOut' | 'center' | 'reset' | 'scrollMode';
export let show: Actions[] = ['zoomIn', 'zoomOut', 'center', 'reset', 'scrollMode'];
Expand Down Expand Up @@ -64,7 +66,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={cls(
'bg-surface-300/50 rounded-full m-1 backdrop-blur z-10 flex',
'bg-surface-300/50 border rounded-full m-1 backdrop-blur z-10 flex',
orientation === 'vertical' && 'flex-col',
{
'top-left': 'absolute top-0 left-0',
Expand All @@ -89,6 +91,7 @@
<Button
icon={mdiMagnifyPlusOutline}
on:click={() => transform.zoomIn()}
{size}
class="text-surface-content p-2"
/>
</Tooltip>
Expand All @@ -99,6 +102,7 @@
<Button
icon={mdiMagnifyMinusOutline}
on:click={() => transform.zoomOut()}
{size}
class="text-surface-content p-2"
/>
</Tooltip>
Expand All @@ -109,6 +113,7 @@
<Button
icon={mdiImageFilterCenterFocus}
on:click={() => transform.translateCenter()}
{size}
class="text-surface-content p-2"
/>
</Tooltip>
Expand All @@ -119,6 +124,7 @@
<Button
icon={mdiArrowULeftTop}
on:click={() => transform.reset()}
{size}
class="text-surface-content p-2"
/>
</Tooltip>
Expand All @@ -135,6 +141,7 @@
]}
menuProps={{ placement: menuPlacementByOrientationAndPlacement[orientation][placement] }}
menuIcon={null}
{size}
value={$scrollMode}
on:change={(e) => transform.setScrollMode(e.detail.value)}
class="text-surface-content"
Expand Down
1 change: 1 addition & 0 deletions packages/layerchart/src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as Circle } from './Circle.svelte';
export { default as CircleClipPath } from './CircleClipPath.svelte';
export { default as ClipPath } from './ClipPath.svelte';
export { default as ColorRamp } from './ColorRamp.svelte';
export { default as Dagre } from './Dagre.svelte';
export { default as Frame } from './Frame.svelte';
export { default as ForceSimulation } from './ForceSimulation.svelte';
export { default as GeoCircle } from './GeoCircle.svelte';
Expand Down
9 changes: 8 additions & 1 deletion packages/layerchart/src/lib/docs/CurveMenuField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@
});
</script>

<MenuField label="Curve" {options} bind:value stepper classes={{ menuIcon: 'hidden' }} />
<MenuField
label="Curve"
{options}
bind:value
stepper
classes={{ menuIcon: 'hidden' }}
{...$$restProps}
/>
98 changes: 98 additions & 0 deletions packages/layerchart/src/lib/utils/graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import dagre from '@dagrejs/dagre';
import { ancestors, descendants } from './graph.js';

const exampleGraph = {
nodes: [
{ id: 'A' },
{ id: 'B' },
{ id: 'C' },
{ id: 'D' },
{ id: 'E' },
{ id: 'F' },
{ id: 'G' },
{ id: 'H' },
{ id: 'I' },
],
edges: [
{ source: 'A', target: 'B' },
{ source: 'C', target: 'B' },
{ source: 'B', target: 'E' },
{ source: 'B', target: 'F' },
{ source: 'D', target: 'E' },
{ source: 'D', target: 'F' },
{ source: 'E', target: 'H' },
{ source: 'G', target: 'H' },
{ source: 'H', target: 'I' },
],
};

function buildGraph(data: typeof exampleGraph) {
const g = new dagre.graphlib.Graph();

g.setGraph({});

data.nodes.forEach((n) => {
g.setNode(n.id, {
label: n.id,
});
});

data.edges.forEach((e) => {
g.setEdge(e.source, e.target);
});

return g;
}

describe('accessors', () => {
it('start of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = ancestors(graph, 'A');
expect(actual).length(0);
});

it('middle of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = ancestors(graph, 'E');
expect(actual).to.have.members(['A', 'B', 'C', 'D']);
});

it('end of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = ancestors(graph, 'I');
expect(actual).to.have.members(['A', 'B', 'C', 'D', 'E', 'G', 'H']);
});

it('max depth', () => {
const graph = buildGraph(exampleGraph);
const actual = ancestors(graph, 'H', 2);
expect(actual).to.have.members(['B', 'D', 'E', 'G']);
});
});

describe('descendants', () => {
it('start of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = descendants(graph, 'A');
expect(actual).to.have.members(['B', 'E', 'F', 'H', 'I']);
});

it('middle of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = descendants(graph, 'E');
expect(actual).to.have.members(['H', 'I']);
});

it('end of graph ', () => {
const graph = buildGraph(exampleGraph);
const actual = descendants(graph, 'I');
expect(actual).length(0);
});

it('max depth', () => {
const graph = buildGraph(exampleGraph);
const actual = descendants(graph, 'B', 2);
expect(actual).to.have.members(['E', 'F', 'H']);
});
});
Loading

0 comments on commit 8213890

Please sign in to comment.