Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A few suggestions for the tree layout #843

Closed
wants to merge 24 commits into from
Closed
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
116 changes: 116 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,121 @@ Plot.stackX2({y: "year", x: "revenue", z: "format", fill: "group"})

Equivalent to [Plot.stackX](#plotstackxstack-options), except that the **x2** channel is returned as the **x** channel. This can be used, for example, to draw a line at the right edge of each stacked area.

### Tree

[<img src="./img/tree.png" width="320" height="198" alt="a node-link tree representing a software hierarchy">](https://observablehq.com/@observablehq/plot-tree)

[Source](./src/transforms/tree.js) · [Examples](https://observablehq.com/@observablehq/plot-tree) · Transforms a tabular dataset into a hierarchy according to the given **path** input channel, which is typically a slash-separated string; then executes a tree layout algorithm to compute **x** and **y** output channels; these channels can then be fed to other marks to construct a node-link diagram.

The following options control how the tabular data is organized into a hierarchy:

* **path** - a column specifying each node’s hierarchy location
* **delimiter** - the path separator; defaults to forward slash (/)

The **path** column is typically slash-separated, as with UNIX-based file systems or URLs. For example, given the following hierarchy:

```
└─ Total
├─ Fossil Fuels
│ ├─ Coal
│ ├─ Natural Gas
│ └─ Crude Oil
├─ Nuclear
└─ Renewable
├─ Biomass
├─ Geothermal
├─ Hydroelectric
├─ Solar
└─ Wind
```

You might use the following path strings:

```
/Total
/Total/Fossil Fuels
/Total/Fossil Fuels/Coal
/Total/Fossil Fuels/Natural Gas
/Total/Fossil Fuels/Crude Oil
/Total/Nuclear
/Total/Renewable
/Total/Renewable/Biomass
/Total/Renewable/Geothermal
/Total/Renewable/Hydroelectric
/Total/Renewable/Solar
/Total/Renewable/Wind
```

The following options control how the node-link diagram is laid out:

* **treeLayout** - a tree layout algorithm; defaults to [d3.tree](https://github.com/d3/d3-hierarchy/blob/main/README.md#tree)
* **treeAnchor** - a tree layout orientation, either *left* or *right*; defaults to *left*
* **treeSort** - a node comparator, or null to preserve input order
* **treeSeparation** - a node separation function, or null for uniform separation

The default **treeLayout** implements the Reingold–Tilford “tidy” algorithm based on Buchheim _et al._’s linear time approach. Use [d3.cluster](https://github.com/d3/d3-hierarchy/blob/main/README.md#cluster) instead to align leaf nodes; see also [Plot.cluster](#plotclusterdata-options). If the **treeAnchor** is *left*, the root of the tree will be aligned with the left side of the frame; if **treeAnchor** is *right*, the root of the tree will be aligned with the right side of the frame; use the **insetLeft** and **insetRight** [scale options](#scale-options) if horizontal padding is desired, say to make room for labels. If the **treeSort** option is not null, it is typically a function that is passed two nodes in the hierarchy and compares them, similar to [_array_.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort); see [d3-hierarchy’s _node_.sort](https://github.com/d3/d3-hierarchy/blob/main/README.md#node_sort) for more. The **treeSort** option can also be specified as a string, in which case it refers either to a named column in data, or if it starts with “node:”, a node value (see below). If the **treeSeparation** is not null, it is a function that is passed two nodes in the hierarchy and returns the desired (relative) amount of separation; see [d3-hierarchy’s _tree_.separation](https://github.com/d3/d3-hierarchy/blob/main/README.md#tree_separation) for more. By default, non-siblings are at least twice as far apart as siblings.

### Plot.treeNode(*options*)

Based on the tree options described above, populates the **x** and **y** channels with the positions for each node. The following defaults are also applied: the default **frameAnchor** inherits the **treeAnchor**. This transform is intended to be used with [dot](#dot), [text](#text), and other point-based marks. This transform is rarely used directly; see the [Plot.tree compound mark](#plottreedata-options).

The treeNode transform will derive output columns for any *options* that have one of the following named node values:

* *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:depth* - the distance from the node to the root
* *node:height* - the distance from the node to its deepest descendant

In addition, if any option value is specified as an object with a **node** method, a derived output column will be generated by invoking the **node** method for each node in the tree.

### Plot.treeLink(*options*)

Based on the tree options described above, populates the **x1**, **y1**, **x2**, and **y2** channels. The following defaults are also applied: the default **curve** is *bump-x*, the default **stroke** is #555, the default **strokeWidth** is 1.5, and the default **strokeOpacity** is 0.5. This transform is intended to be used with [link](#link), [arrow](#arrow), and other two-point-based marks. This transform is rarely used directly; see the [Plot.tree compound mark](#plottreedata-options).

The treeLink transform will derive output columns for any *options* that have one of the following named link values:

* *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: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)
* *parent:path* - the parent node’s full, normalized, slash-separated path
* *parent:depth* - the distance from the parent node to the root
* *parent:height* - the distance from the parent node to its deepest descendant

In addition, if any option value is specified as an object with a **node** method, a derived output column will be generated by invoking the **node** method for each child node in the tree; likewise if any option value is specified as an object with a **link** method, a derived output column will be generated by invoking the **link** method for each link in the tree, being passed two node arguments, the child and the parent.

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

A convenience compound mark for rendering a tree diagram, including a [link](#link) to render links from parent to child, an optional [dot](#dot) for nodes, and a [text](#text) for node labels. The link mark uses the [treeLink transform](#plottreelinkoptions), while the dot and text marks use the [treeNode transform](#plottreenodeoptions). The following options are supported:

* **fill** - the dot and text fill color; defaults to *node:internal*
* **stroke** - the link stroke color; inherits **fill** by default
* **strokeWidth** - the link stroke width
* **strokeOpacity** - the link stroke opacity
* **strokeLinejoin** - the link stroke linejoin
* **strokeLinecap** - the link stroke linecap
* **strokeMiterlimit** - the link stroke miter limit
* **strokeDasharray** - the link stroke dash array
* **strokeDashoffset** - the link stroke dash offset
* **marker** - the link start and end marker
* **markerStart** - the link start marker
* **markerEnd** - the link end marker
* **dot** - if true, whether to render a dot; defaults to false if no link marker
* **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
* **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.

### Plot.cluster(*data*, *options*)

Like Plot.tree, except sets the **treeLayout** option to D3’s cluster (dendrogram) algorithm, which aligns leaf nodes.

### Custom transforms

The **transform** option defines a custom transform function, allowing data, indexes, or channels to be derived prior to rendering. Custom transforms are rarely implemented directly; see the built-in transforms above. The transform function (if present) is passed two arguments, *data* and *facets*, representing the mark’s data and facet indexes; it must then return a {data, facets} object representing the resulting transformed data and facet indexes. The *facets* are represented as a nested array of arrays such as [[0, 1, 3, …], [2, 5, 10, …], …]; each element in *facets* specifies the zero-based indexes of elements in *data* that are in a given facet (*i.e.*, have a distinct value in the associated *fx* or *fy* dimension).
Expand Down Expand Up @@ -2078,6 +2193,7 @@ The following named markers are supported:

* *none* - no marker (default)
* *arrow* - an arrowhead
* *dot* - a filled *circle* without a stroke and 2.5px radius
* *circle*, equivalent to *circle-fill* - a filled circle with a white stroke and 3px radius
* *circle-stroke* - a hollow circle with a colored stroke and a white fill and 3px radius

Expand Down
Binary file added img/tree.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {tree, cluster} from "./marks/tree.js";
export {Vector, vector, vectorX, vectorY} from "./marks/vector.js";
export {valueof, channel} from "./options.js";
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
Expand All @@ -23,6 +24,7 @@ export {map, mapX, mapY} from "./transforms/map.js";
export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {treeNode, treeLink} from "./transforms/tree.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
export {legend} from "./legends.js";
12 changes: 12 additions & 0 deletions src/marks/marker.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function maybeMarker(marker) {
switch (`${marker}`.toLowerCase()) {
case "none": return null;
case "arrow": return markerArrow;
case "dot": return markerDot;
case "circle": case "circle-fill": return markerCircleFill;
case "circle-stroke": return markerCircleStroke;
}
Expand All @@ -39,6 +40,17 @@ function markerArrow(color) {
.node();
}

function markerDot(color) {
return create("svg:marker")
.attr("viewBox", "-5 -5 10 10")
.attr("markerWidth", 6.67)
.attr("markerHeight", 6.67)
.attr("fill", color)
.attr("stroke", "none")
.call(marker => marker.append("circle").attr("r", 2.5))
.node();
}

function markerCircleFill(color) {
return create("svg:marker")
.attr("viewBox", "-5 -5 10 10")
Expand Down
48 changes: 48 additions & 0 deletions src/marks/tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {cluster as Cluster} from "d3";
import {isNoneish} from "../options.js";
import {marks} from "../plot.js";
import {maybeTreeAnchor, treeLink, treeNode} from "../transforms/tree.js";
import {dot} from "./dot.js";
import {link} from "./link.js";
import {text} from "./text.js";

export function tree(data, {
fill,
stroke,
strokeWidth,
strokeOpacity,
strokeLinejoin,
strokeLinecap,
strokeMiterlimit,
strokeDasharray,
strokeDashoffset,
marker,
markerStart = marker,
markerEnd = marker,
dot: dotDot = isNoneish(markerStart) && isNoneish(markerEnd),
text: textText = "node:name",
textStroke = "white",
title = "node:path",
dx,
dy,
...options
} = {}) {
if (dx === undefined) dx = maybeTreeAnchor(options.treeAnchor).dx;
const m = marks(
link(data, treeLink({markerStart, markerEnd, stroke: stroke !== undefined ? stroke : fill === undefined ? "node:internal" : fill, strokeWidth, strokeOpacity, strokeLinejoin, strokeLinecap, strokeMiterlimit, strokeDasharray, strokeDashoffset, ...options})),
dotDot ? dot(data, treeNode({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})) : null
);

// default options for shorthand tree().plot()
const plot = m.plot;
// {insetLeft: 10, insetTop: 20, insetBottom: 20, insetRight: 120, x: {axis: null}, y: {axis: null}}
m.plot = function({x = {axis: null}, y = {axis: null}, inset = undefined, insetLeft = inset != null ? inset : 10, insetTop = 20, insetBottom = 20, insetRight = 120, ...options} = {}) {
return plot.call(this, {x, y, insetLeft, insetRight, insetTop, insetBottom, ...options});
};
Comment on lines +40 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this, too, but I don’t think the mark.plot shorthand should have different defaults from Plot.plot. If we want these hints we should figure out how to do them as channel hints.

return m;
}

export function cluster(data, options) {
return tree(data, {...options, treeLayout: Cluster});
}
1 change: 1 addition & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const field = name => d => d[name];
export const indexOf = (d, i) => i;
export const identity = {transform: d => d};
export const zero = () => 0;
export const one = () => 1;
export const string = x => x == null ? x : `${x}`;
export const number = x => x == null ? x : +x;
export const boolean = x => x == null ? x : !!x;
Expand Down
4 changes: 2 additions & 2 deletions src/transforms/stack.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3";
import {ascendingDefined} from "../defined.js";
import {field, channel, maybeChannel, maybeZ, mid, range, valueof, maybeZero} from "../options.js";
import {field, channel, maybeChannel, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js";
import {basic} from "./basic.js";

export function stackX(stackOptions = {}, options = {}) {
Expand Down Expand Up @@ -65,7 +65,7 @@ function mergeOptions(options) {
return [{offset, order, reverse}, rest];
}

function stack(x, y = () => 1, ky, {offset, order, reverse}, options) {
function stack(x, y = one, ky, {offset, order, reverse}, options) {
const z = maybeZ(options);
const [X, setX] = maybeChannel(x);
const [Y1, setY1] = channel(y);
Expand Down
Loading