Skip to content

Commit

Permalink
balanced tree (observablehq#1610)
Browse files Browse the repository at this point in the history
Co-authored-by: Mike Bostock <mbostock@gmail.com>
  • Loading branch information
2 people authored and chaichontat committed Jan 14, 2024
1 parent 475e2dc commit 51470d6
Show file tree
Hide file tree
Showing 12 changed files with 890 additions and 824 deletions.
19 changes: 15 additions & 4 deletions docs/marks/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function indent() {

# Tree mark

The **tree mark** produces tree diagrams using the [tree transform](../transforms/tree.md). It is a [composite mark](../features/marks.md#marks-marks), consisting of a [link](./link.md) to render links from parent to child, an optional [dot](./dot.md) for nodes, and a [text](./text.md) for node labels. The link mark uses the [treeLink transform](../transforms/tree.md#treelink-options), while the dot and text marks use the [treeNode transform](../transforms/tree.md#treenode-options).
The **tree mark** produces tree diagrams using the [tree transform](../transforms/tree.md). It is a [composite mark](../features/marks.md#marks-marks), consisting of a [link](./link.md) to render links from parent to child, an optional [dot](./dot.md) for nodes, and one or two [text](./text.md) for node labels. The link mark uses the [treeLink transform](../transforms/tree.md#treelink-options), while the dot and text marks use the [treeNode transform](../transforms/tree.md#treenode-options).

For example, here is a little family tree of Greek gods.

Expand All @@ -41,7 +41,8 @@ For example, here is a little family tree of Greek gods.
Plot.plot({
axis: null,
height: 100,
margin: 20,
margin: 10,
marginLeft: 35,
marginRight: 120,
marks: [
Plot.tree(gods, {textStroke: "var(--vp-c-bg)"})
Expand All @@ -63,6 +64,7 @@ As a more complete example, here is a visualization of a software package hierar
Plot.plot({
axis: null,
margin: 10,
marginLeft: 30,
marginRight: 160,
width: 688,
height: 1800,
Expand All @@ -80,6 +82,7 @@ The **treeLayout** option specifies the layout algorithm. The tree mark uses the
Plot.plot({
axis: null,
margin: 10,
marginLeft: 30,
marginRight: 160,
width: 688,
height: 2400,
Expand Down Expand Up @@ -148,11 +151,19 @@ The following options are supported:
* **title** - the text and dot title; defaults to *node:path*
* **text** - the text label; defaults to *node:name*
* **textStroke** - the text stroke; defaults to *white*
* **dx** - the text horizontal offset; defaults to 6 if left-anchored, or -6 if right-anchored
* **textLayout** - the text anchoring layout
* **dx** - the text horizontal offset; defaults to 6
* **dy** - the text vertical offset; defaults to 0

Any additional *options* are passed through to the constituent link, dot, and text marks and their corresponding treeLink or treeNode transform.

The **textLayout** option controls how text labels are anchored to the node. Two layouts are supported:

* *mirrored* - leaf-node labels are left-anchored, and non-leaf nodes right-anchored
* *normal* - all labels are left-anchored

If the **treeLayout** is d3.tree or d3.cluster, the **textLayout** defaults to *mirrored*; otherwise it defaults to *normal*.

## tree(*data*, *options*)

```js
Expand All @@ -167,4 +178,4 @@ Returns a new tree mark with the given *data* and *options*.
Plot.cluster(flare, {path: "name", delimiter: "."})
```

Like [tree](#tree-data-options), except sets the **treeLayout** option to [d3.cluster](https://github.com/d3/d3-hierarchy/blob/main/README.md#cluster), aligning leaf nodes.
Like [tree](#tree-data-options), except sets the **treeLayout** option to [d3.cluster](https://github.com/d3/d3-hierarchy/blob/main/README.md#cluster), aligning leaf nodes, and defaults the **textLayout** option to *mirrored*.
2 changes: 2 additions & 0 deletions docs/transforms/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ The treeNode transform will derive output columns for any *options* that have on
* *node:name* - the node’s name (the last part of its path)
* *node:path* - the node’s full, normalized, slash-separated path
* *node:internal* - true if the node is internal, or false for leaves
* *node:external* - true if the node is a leaf, or false for internal nodes
* *node:depth* - the distance from the node to the root
* *node:height* - the distance from the node to its deepest descendant

Expand All @@ -102,6 +103,7 @@ The treeLink transform will likewise derive output columns for any *options* tha
* *node:name* - the child node’s name (the last part of its path)
* *node:path* - the child node’s full, normalized, slash-separated path
* *node:internal* - true if the child node is internal, or false for leaves
* *node:external* - true if the child node is a leaf, or false for internal nodes
* *node:depth* - the distance from the child node to the root
* *node:height* - the distance from the child node to its deepest descendant
* *parent:name* - the parent node’s name (the last part of its path)
Expand Down
12 changes: 10 additions & 2 deletions src/marks/tree.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {DotOptions} from "./dot.js";
import type {LinkOptions} from "./link.js";
import type {TextOptions} from "./text.js";

// TODO tree channels, e.g., "node:name" | "node:path" | "node:internal"?
// TODO tree channels, e.g., "node:name" | "node:path" | "node:internal" | "node:external"?

/** Options for the compound tree mark. */
export interface TreeOptions extends DotOptions, LinkOptions, TextOptions, TreeTransformOptions {
Expand All @@ -19,6 +19,14 @@ export interface TreeOptions extends DotOptions, LinkOptions, TextOptions, TreeT
* atop other marks by creating a halo effect; defaults to *white*.
*/
textStroke?: MarkOptions["stroke"];

/**
* Layout for node labels: if *mirrored*, leaf-node labels are left-anchored,
* and non-leaf nodes right-anchored (with a -dx offset). If *normal*, all
* labels are left-anchored. Defaults to *mirrored* unless a **treeLayout**
* has been specified.
*/
textLayout?: "mirrored" | "normal";
}

/**
Expand All @@ -40,7 +48,7 @@ export function tree(data?: Data, options?: TreeOptions): CompoundMark;
* option, placing leaf nodes of the tree at the same depth. Equivalent to:
*
* ```js
* Plot.tree(data, {...options, treeLayout: d3.cluster})
* Plot.tree(data, {...options, treeLayout: d3.cluster, textLayout: "mirrored"})
* ```
*
* [1]: https://github.com/d3/d3-hierarchy/blob/main/README.md#cluster
Expand Down
51 changes: 36 additions & 15 deletions src/marks/tree.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {cluster as Cluster} from "d3";
import {isNoneish} from "../options.js";
import {cluster as Cluster, tree as Tree} from "d3";
import {marks} from "../mark.js";
import {isNoneish} from "../options.js";
import {maybeTreeAnchor, treeLink, treeNode} from "../transforms/tree.js";
import {dot} from "./dot.js";
import {link} from "./link.js";
import {text} from "./text.js";
import {keyword} from "../options.js";

export function tree(
data,
Expand All @@ -27,14 +28,38 @@ export function tree(
title = "node:path",
dx,
dy,
textAnchor,
treeLayout = Tree,
textLayout = treeLayout === Tree || treeLayout === Cluster ? "mirrored" : "normal",
...options
} = {}
) {
if (dx === undefined) dx = maybeTreeAnchor(options.treeAnchor).dx;
if (textAnchor !== undefined) throw new Error("textAnchor is not a configurable tree option");
textLayout = keyword(textLayout, "textLayout", ["mirrored", "normal"]);

function treeText(textOptions) {
return text(
data,
treeNode({
treeLayout,
text: textText,
fill: fill === undefined ? "currentColor" : fill,
stroke: textStroke,
dx,
dy,
title,
...textOptions,
...options
})
);
}

return marks(
link(
data,
treeLink({
treeLayout,
markerStart,
markerEnd,
stroke: stroke !== undefined ? stroke : fill === undefined ? "node:internal" : fill,
Expand All @@ -48,20 +73,16 @@ export function tree(
...options
})
),
dotDot ? dot(data, treeNode({fill: fill === undefined ? "node:internal" : fill, title, ...options})) : null,
dotDot
? dot(data, treeNode({treeLayout, fill: fill === undefined ? "node:internal" : fill, title, ...options}))
: null,
textText != null
? text(
data,
treeNode({
text: textText,
fill: fill === undefined ? "currentColor" : fill,
stroke: textStroke,
dx,
dy,
title,
...options
})
)
? textLayout === "mirrored"
? [
treeText({textAnchor: "start", treeFilter: "node:external"}),
treeText({textAnchor: "end", treeFilter: "node:internal", dx: -dx})
]
: treeText()
: null
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/transforms/tree.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface TreeTransformOptions {
* * *node:name* - the node’s name (the last part of its path)
* * *node:path* - the node’s full, normalized, slash-separated path
* * *node:internal* - true if the node is internal, or false for leaves
* * *node:external* - true if the node is a leaf, or false for internal nodes
* * *node:depth* - the distance from the node to the root
* * *node:height* - the distance from the node to its deepest descendant
*
Expand All @@ -97,6 +98,7 @@ export function treeNode<T>(options?: T & TreeTransformOptions): Transformed<T>;
* * *node:name* - the child node’s name (the last part of its path)
* * *node:path* - the child node’s full, normalized, slash-separated path
* * *node:internal* - true if the child node is internal, or false for leaves
* * *node:external* - true if the child node is a leaf, or false for external nodes
* * *node:depth* - the distance from the child node to the root
* * *node:height* - the distance from the child node to its deepest descendant
* * *parent:name* - the parent node’s name (the last part of its path)
Expand Down
14 changes: 14 additions & 0 deletions src/transforms/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export function treeNode({
treeSort,
treeSeparation,
treeAnchor,
treeFilter,
...options
} = {}) {
treeAnchor = maybeTreeAnchor(treeAnchor);
treeSort = maybeTreeSort(treeSort);
if (treeFilter != null) treeFilter = maybeNodeValue(treeFilter);
if (frameAnchor === undefined) frameAnchor = treeAnchor.frameAnchor;
const normalize = normalizer(delimiter);
const outputs = treeOutputs(options, maybeNodeValue);
Expand Down Expand Up @@ -42,6 +44,7 @@ export function treeNode({
if (treeSort != null) root.sort(treeSort);
layout(root);
for (const node of root.descendants()) {
if (treeFilter != null && !treeFilter(node)) continue;
treeFacet.push(++treeIndex);
treeData[treeIndex] = node.data;
treeAnchor.position(node, treeIndex, X, Y);
Expand All @@ -66,10 +69,12 @@ export function treeLink({
treeSort,
treeSeparation,
treeAnchor,
treeFilter,
...options
} = {}) {
treeAnchor = maybeTreeAnchor(treeAnchor);
treeSort = maybeTreeSort(treeSort);
if (treeFilter != null) treeFilter = maybeLinkValue(treeFilter);
options = {curve, stroke, strokeWidth, strokeOpacity, ...options};
const normalize = normalizer(delimiter);
const outputs = treeOutputs(options, maybeLinkValue);
Expand Down Expand Up @@ -102,6 +107,7 @@ export function treeLink({
if (treeSort != null) root.sort(treeSort);
layout(root);
for (const {source, target} of root.links()) {
if (treeFilter != null && !treeFilter(target, source)) continue;
treeFacet.push(++treeIndex);
treeData[treeIndex] = target.data;
treeAnchor.position(source, treeIndex, X1, Y1);
Expand Down Expand Up @@ -194,6 +200,8 @@ function maybeNodeValue(value) {
return nodePath;
case "node:internal":
return nodeInternal;
case "node:external":
return nodeExternal;
case "node:depth":
return nodeDepth;
case "node:height":
Expand Down Expand Up @@ -222,6 +230,8 @@ function maybeLinkValue(value) {
return nodePath;
case "node:internal":
return nodeInternal;
case "node:external":
return nodeExternal;
case "node:depth":
return nodeDepth;
case "node:height":
Expand Down Expand Up @@ -250,6 +260,10 @@ function nodeInternal(node) {
return !!node.children;
}

function nodeExternal(node) {
return !node.children;
}

function parentValue(evaluate) {
return (child, parent) => (parent == null ? undefined : evaluate(parent));
}
Expand Down
Loading

0 comments on commit 51470d6

Please sign in to comment.