Skip to content

Commit 36e5bc4

Browse files
Filmbostock
andauthored
balanced tree (#1610)
Co-authored-by: Mike Bostock <[email protected]>
1 parent 3d1c4f3 commit 36e5bc4

File tree

12 files changed

+890
-824
lines changed

12 files changed

+890
-824
lines changed

docs/marks/tree.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function indent() {
3232

3333
# Tree mark
3434

35-
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).
35+
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).
3636

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

@@ -41,7 +41,8 @@ For example, here is a little family tree of Greek gods.
4141
Plot.plot({
4242
axis: null,
4343
height: 100,
44-
margin: 20,
44+
margin: 10,
45+
marginLeft: 35,
4546
marginRight: 120,
4647
marks: [
4748
Plot.tree(gods, {textStroke: "var(--vp-c-bg)"})
@@ -63,6 +64,7 @@ As a more complete example, here is a visualization of a software package hierar
6364
Plot.plot({
6465
axis: null,
6566
margin: 10,
67+
marginLeft: 30,
6668
marginRight: 160,
6769
width: 688,
6870
height: 1800,
@@ -80,6 +82,7 @@ The **treeLayout** option specifies the layout algorithm. The tree mark uses the
8082
Plot.plot({
8183
axis: null,
8284
margin: 10,
85+
marginLeft: 30,
8386
marginRight: 160,
8487
width: 688,
8588
height: 2400,
@@ -148,11 +151,19 @@ The following options are supported:
148151
* **title** - the text and dot title; defaults to *node:path*
149152
* **text** - the text label; defaults to *node:name*
150153
* **textStroke** - the text stroke; defaults to *white*
151-
* **dx** - the text horizontal offset; defaults to 6 if left-anchored, or -6 if right-anchored
154+
* **textLayout** - the text anchoring layout
155+
* **dx** - the text horizontal offset; defaults to 6
152156
* **dy** - the text vertical offset; defaults to 0
153157

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

160+
The **textLayout** option controls how text labels are anchored to the node. Two layouts are supported:
161+
162+
* *mirrored* - leaf-node labels are left-anchored, and non-leaf nodes right-anchored
163+
* *normal* - all labels are left-anchored
164+
165+
If the **treeLayout** is d3.tree or d3.cluster, the **textLayout** defaults to *mirrored*; otherwise it defaults to *normal*.
166+
156167
## tree(*data*, *options*)
157168

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

170-
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.
181+
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*.

docs/transforms/tree.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ The treeNode transform will derive output columns for any *options* that have on
8888
* *node:name* - the node’s name (the last part of its path)
8989
* *node:path* - the node’s full, normalized, slash-separated path
9090
* *node:internal* - true if the node is internal, or false for leaves
91+
* *node:external* - true if the node is a leaf, or false for internal nodes
9192
* *node:depth* - the distance from the node to the root
9293
* *node:height* - the distance from the node to its deepest descendant
9394

@@ -102,6 +103,7 @@ The treeLink transform will likewise derive output columns for any *options* tha
102103
* *node:name* - the child node’s name (the last part of its path)
103104
* *node:path* - the child node’s full, normalized, slash-separated path
104105
* *node:internal* - true if the child node is internal, or false for leaves
106+
* *node:external* - true if the child node is a leaf, or false for internal nodes
105107
* *node:depth* - the distance from the child node to the root
106108
* *node:height* - the distance from the child node to its deepest descendant
107109
* *parent:name* - the parent node’s name (the last part of its path)

src/marks/tree.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {DotOptions} from "./dot.js";
44
import type {LinkOptions} from "./link.js";
55
import type {TextOptions} from "./text.js";
66

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

99
/** Options for the compound tree mark. */
1010
export interface TreeOptions extends DotOptions, LinkOptions, TextOptions, TreeTransformOptions {
@@ -19,6 +19,14 @@ export interface TreeOptions extends DotOptions, LinkOptions, TextOptions, TreeT
1919
* atop other marks by creating a halo effect; defaults to *white*.
2020
*/
2121
textStroke?: MarkOptions["stroke"];
22+
23+
/**
24+
* Layout for node labels: if *mirrored*, leaf-node labels are left-anchored,
25+
* and non-leaf nodes right-anchored (with a -dx offset). If *normal*, all
26+
* labels are left-anchored. Defaults to *mirrored* unless a **treeLayout**
27+
* has been specified.
28+
*/
29+
textLayout?: "mirrored" | "normal";
2230
}
2331

2432
/**
@@ -40,7 +48,7 @@ export function tree(data?: Data, options?: TreeOptions): CompoundMark;
4048
* option, placing leaf nodes of the tree at the same depth. Equivalent to:
4149
*
4250
* ```js
43-
* Plot.tree(data, {...options, treeLayout: d3.cluster})
51+
* Plot.tree(data, {...options, treeLayout: d3.cluster, textLayout: "mirrored"})
4452
* ```
4553
*
4654
* [1]: https://github.com/d3/d3-hierarchy/blob/main/README.md#cluster

src/marks/tree.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import {cluster as Cluster} from "d3";
2-
import {isNoneish} from "../options.js";
1+
import {cluster as Cluster, tree as Tree} from "d3";
32
import {marks} from "../mark.js";
3+
import {isNoneish} from "../options.js";
44
import {maybeTreeAnchor, treeLink, treeNode} from "../transforms/tree.js";
55
import {dot} from "./dot.js";
66
import {link} from "./link.js";
77
import {text} from "./text.js";
8+
import {keyword} from "../options.js";
89

910
export function tree(
1011
data,
@@ -27,14 +28,38 @@ export function tree(
2728
title = "node:path",
2829
dx,
2930
dy,
31+
textAnchor,
32+
treeLayout = Tree,
33+
textLayout = treeLayout === Tree || treeLayout === Cluster ? "mirrored" : "normal",
3034
...options
3135
} = {}
3236
) {
3337
if (dx === undefined) dx = maybeTreeAnchor(options.treeAnchor).dx;
38+
if (textAnchor !== undefined) throw new Error("textAnchor is not a configurable tree option");
39+
textLayout = keyword(textLayout, "textLayout", ["mirrored", "normal"]);
40+
41+
function treeText(textOptions) {
42+
return text(
43+
data,
44+
treeNode({
45+
treeLayout,
46+
text: textText,
47+
fill: fill === undefined ? "currentColor" : fill,
48+
stroke: textStroke,
49+
dx,
50+
dy,
51+
title,
52+
...textOptions,
53+
...options
54+
})
55+
);
56+
}
57+
3458
return marks(
3559
link(
3660
data,
3761
treeLink({
62+
treeLayout,
3863
markerStart,
3964
markerEnd,
4065
stroke: stroke !== undefined ? stroke : fill === undefined ? "node:internal" : fill,
@@ -48,20 +73,16 @@ export function tree(
4873
...options
4974
})
5075
),
51-
dotDot ? dot(data, treeNode({fill: fill === undefined ? "node:internal" : fill, title, ...options})) : null,
76+
dotDot
77+
? dot(data, treeNode({treeLayout, fill: fill === undefined ? "node:internal" : fill, title, ...options}))
78+
: null,
5279
textText != null
53-
? text(
54-
data,
55-
treeNode({
56-
text: textText,
57-
fill: fill === undefined ? "currentColor" : fill,
58-
stroke: textStroke,
59-
dx,
60-
dy,
61-
title,
62-
...options
63-
})
64-
)
80+
? textLayout === "mirrored"
81+
? [
82+
treeText({textAnchor: "start", treeFilter: "node:external"}),
83+
treeText({textAnchor: "end", treeFilter: "node:internal", dx: -dx})
84+
]
85+
: treeText()
6586
: null
6687
);
6788
}

src/transforms/tree.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export interface TreeTransformOptions {
7575
* * *node:name* - the node’s name (the last part of its path)
7676
* * *node:path* - the node’s full, normalized, slash-separated path
7777
* * *node:internal* - true if the node is internal, or false for leaves
78+
* * *node:external* - true if the node is a leaf, or false for internal nodes
7879
* * *node:depth* - the distance from the node to the root
7980
* * *node:height* - the distance from the node to its deepest descendant
8081
*
@@ -97,6 +98,7 @@ export function treeNode<T>(options?: T & TreeTransformOptions): Transformed<T>;
9798
* * *node:name* - the child node’s name (the last part of its path)
9899
* * *node:path* - the child node’s full, normalized, slash-separated path
99100
* * *node:internal* - true if the child node is internal, or false for leaves
101+
* * *node:external* - true if the child node is a leaf, or false for external nodes
100102
* * *node:depth* - the distance from the child node to the root
101103
* * *node:height* - the distance from the child node to its deepest descendant
102104
* * *parent:name* - the parent node’s name (the last part of its path)

src/transforms/tree.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ export function treeNode({
1111
treeSort,
1212
treeSeparation,
1313
treeAnchor,
14+
treeFilter,
1415
...options
1516
} = {}) {
1617
treeAnchor = maybeTreeAnchor(treeAnchor);
1718
treeSort = maybeTreeSort(treeSort);
19+
if (treeFilter != null) treeFilter = maybeNodeValue(treeFilter);
1820
if (frameAnchor === undefined) frameAnchor = treeAnchor.frameAnchor;
1921
const normalize = normalizer(delimiter);
2022
const outputs = treeOutputs(options, maybeNodeValue);
@@ -42,6 +44,7 @@ export function treeNode({
4244
if (treeSort != null) root.sort(treeSort);
4345
layout(root);
4446
for (const node of root.descendants()) {
47+
if (treeFilter != null && !treeFilter(node)) continue;
4548
treeFacet.push(++treeIndex);
4649
treeData[treeIndex] = node.data;
4750
treeAnchor.position(node, treeIndex, X, Y);
@@ -66,10 +69,12 @@ export function treeLink({
6669
treeSort,
6770
treeSeparation,
6871
treeAnchor,
72+
treeFilter,
6973
...options
7074
} = {}) {
7175
treeAnchor = maybeTreeAnchor(treeAnchor);
7276
treeSort = maybeTreeSort(treeSort);
77+
if (treeFilter != null) treeFilter = maybeLinkValue(treeFilter);
7378
options = {curve, stroke, strokeWidth, strokeOpacity, ...options};
7479
const normalize = normalizer(delimiter);
7580
const outputs = treeOutputs(options, maybeLinkValue);
@@ -102,6 +107,7 @@ export function treeLink({
102107
if (treeSort != null) root.sort(treeSort);
103108
layout(root);
104109
for (const {source, target} of root.links()) {
110+
if (treeFilter != null && !treeFilter(target, source)) continue;
105111
treeFacet.push(++treeIndex);
106112
treeData[treeIndex] = target.data;
107113
treeAnchor.position(source, treeIndex, X1, Y1);
@@ -194,6 +200,8 @@ function maybeNodeValue(value) {
194200
return nodePath;
195201
case "node:internal":
196202
return nodeInternal;
203+
case "node:external":
204+
return nodeExternal;
197205
case "node:depth":
198206
return nodeDepth;
199207
case "node:height":
@@ -222,6 +230,8 @@ function maybeLinkValue(value) {
222230
return nodePath;
223231
case "node:internal":
224232
return nodeInternal;
233+
case "node:external":
234+
return nodeExternal;
225235
case "node:depth":
226236
return nodeDepth;
227237
case "node:height":
@@ -250,6 +260,10 @@ function nodeInternal(node) {
250260
return !!node.children;
251261
}
252262

263+
function nodeExternal(node) {
264+
return !node.children;
265+
}
266+
253267
function parentValue(evaluate) {
254268
return (child, parent) => (parent == null ? undefined : evaluate(parent));
255269
}

0 commit comments

Comments
 (0)