From 6498cc79c8d2d152d565306952ebe35370e65187 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 4 Dec 2021 09:07:46 -0800 Subject: [PATCH] stratify.path (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stratify.path * simplify root test * null data for imputed nodes * minimize imputed roots * document stratify.path * allow escape sequences * Update README Co-authored-by: Philippe Rivière --- README.md | 88 +++++---- src/stratify.js | 88 ++++++++- test/stratify-test.js | 428 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 553 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 4703a3cf..69e4f7e8 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ const tree = d3.treemap(); Before you can compute a hierarchical layout, you need a root node. If your data is already in a hierarchical format, such as JSON, you can pass it directly to [d3.hierarchy](#hierarchy); otherwise, you can rearrange tabular data, such as comma-separated values (CSV), into a hierarchy using [d3.stratify](#stratify). -# d3.hierarchy(data[, children]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/index.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# d3.hierarchy(data[, children]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Constructs a root node from the specified hierarchical *data*. The specified *data* must be an object representing the root node. For example: @@ -106,7 +106,7 @@ function children(d) { } ``` -This allows you to pass the result of [d3.group](https://github.com/d3/d3-array/blob/master/README.md#group) or [d3.rollup](https://github.com/d3/d3-array/blob/master/README.md#rollup) to d3.hierarchy. +This allows you to pass the result of [d3.group](https://github.com/d3/d3-array/blob/main/README.md#group) or [d3.rollup](https://github.com/d3/d3-array/blob/main/README.md#rollup) to d3.hierarchy. The returned node and each descendant has the following properties: @@ -119,31 +119,31 @@ The returned node and each descendant has the following properties: This method can also be used to test if a node is an `instanceof d3.hierarchy` and to extend the node prototype. -# node.ancestors() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/ancestors.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.ancestors() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/ancestors.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Returns the array of ancestors nodes, starting with this node, then followed by each parent up to the root. -# node.descendants() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/descendants.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.descendants() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/descendants.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Returns the array of descendant nodes, starting with this node, then followed by each child in topological order. -# node.leaves() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/leaves.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.leaves() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/leaves.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Returns the array of leaf nodes in traversal order; leaves are nodes with no children. -# node.find(filter) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/find.js) +# node.find(filter) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/find.js) Returns the first node in the hierarchy from this *node* for which the specified *filter* returns a truthy value. undefined if no such node is found. -# node.path(target) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/path.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.path(target) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/path.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Returns the shortest path through the hierarchy from this *node* to the specified *target* node. The path starts at this *node*, ascends to the least common ancestor of this *node* and the *target* node, and then descends to the *target* node. This is particularly useful for [hierarchical edge bundling](https://observablehq.com/@d3/hierarchical-edge-bundling). -# node.links() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/links.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.links() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/links.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Returns an array of links for this *node* and its descendants, where each *link* is an object that defines source and target properties. The source of each link is the parent node, and the target is a child node. -# node.sum(value) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/sum.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.sum(value) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/sum.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Evaluates the specified *value* function for this *node* and each descendant in [post-order traversal](#node_eachAfter), and returns this *node*. The *node*.value property of each node is set to the numeric value returned by the specified function plus the combined value of all children. The function is passed the node’s data, and must return a non-negative number. The *value* accessor is evaluated for *node* and every descendant, including internal nodes; if you only want leaf nodes to have internal value, then return zero for any node with children. [For example](https://observablehq.com/@d3/treemap-by-count), as an alternative to [*node*.count](#node_count): @@ -166,11 +166,11 @@ var nodes = treemap(root This example assumes that the node data has a value field. -# node.count() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/count.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.count() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/count.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Computes the number of leaves under this *node* and assigns it to *node*.value, and similarly for every descendant of *node*. If this *node* is a leaf, its count is one. Returns this *node*. See also [*node*.sum](#node_sum). -# node.sort(compare) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/sort.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.sort(compare) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/sort.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Sorts the children of this *node*, if any, and each of this *node*’s descendants’ children, in [pre-order traversal](#node_eachBefore) using the specified *compare* function, and returns this *node*. The specified function is passed two nodes *a* and *b* to compare. If *a* should be before *b*, the function must return a value less than zero; if *b* should be before *a*, the function must return a value greater than zero; otherwise, the relative order of *a* and *b* are not specified. See [*array*.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) for more. @@ -200,7 +200,7 @@ root You must call *node*.sort before invoking a hierarchical layout if you want the new sort order to affect the layout; see [*node*.sum](#node_sum) for an example. -# node\[Symbol.iterator\]() [<>](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/iterator.js "Source") +# node\[Symbol.iterator\]() [<>](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/iterator.js "Source") Returns an iterator over the *node*’s descendants in breadth-first order. For example: @@ -210,19 +210,19 @@ for (const descendant of node) { } ``` -# node.each(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/each.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.each(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/each.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Invokes the specified *function* for *node* and each descendant in [breadth-first order](https://en.wikipedia.org/wiki/Breadth-first_search), such that a given *node* is only visited if all nodes of lesser depth have already been visited, as well as all preceding nodes of the same depth. The specified function is passed the current *descendant*, the zero-based traversal *index*, and this *node*. If *that* is specified, it is the this context of the callback. -# node.eachAfter(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/eachAfter.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.eachAfter(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Invokes the specified *function* for *node* and each descendant in [post-order traversal](https://en.wikipedia.org/wiki/Tree_traversal#Post-order), such that a given *node* is only visited after all of its descendants have already been visited. The specified function is passed the current *descendant*, the zero-based traversal *index*, and this *node*. If *that* is specified, it is the this context of the callback. -# node.eachBefore(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/eachBefore.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) +# node.eachBefore(function[, that]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js), [Examples](https://observablehq.com/@d3/visiting-a-d3-hierarchy) Invokes the specified *function* for *node* and each descendant in [pre-order traversal](https://en.wikipedia.org/wiki/Tree_traversal#Pre-order), such that a given *node* is only visited after all of its ancestors have already been visited. The specified function is passed the current *descendant*, the zero-based traversal *index*, and this *node*. If *that* is specified, it is the this context of the callback. -# node.copy() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/hierarchy/index.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) +# node.copy() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js), [Examples](https://observablehq.com/@d3/d3-hierarchy) Return a deep copy of the subtree starting at this *node*. (The returned deep copy shares the same data, however.) The returned node is the root of a new tree; the returned node’s parent is always null and its depth is always zero. @@ -290,19 +290,19 @@ var root = d3.stratify() This returns: -[Stratify](https://runkit.com/mbostock/56fed33d8630b01300f72daa) +[Stratify](https://runkit.com/mbostock/56fed33d8630b01300f72daa) This hierarchy can now be passed to a hierarchical layout, such as [d3.tree](#_tree), for visualization. -# d3.stratify() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) +# d3.stratify() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) Constructs a new stratify operator with the default settings. -# stratify(data) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) +# stratify(data) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) Generates a new hierarchy from the specified tabular *data*. -# stratify.id([id]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) +# stratify.id([id]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) If *id* is specified, sets the id accessor to the given function and returns this stratify operator. Otherwise, returns the current id accessor, which defaults to: @@ -314,7 +314,7 @@ function id(d) { The id accessor is invoked for each element in the input data passed to the [stratify operator](#_stratify), being passed the current datum (*d*) and the current index (*i*). The returned string is then used to identify the node’s relationships in conjunction with the [parent id](#stratify_parentId). For leaf nodes, the id may be undefined; otherwise, the id must be unique. (Null and the empty string are equivalent to undefined.) -# stratify.parentId([parentId]) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) +# stratify.parentId([parentId]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) If *parentId* is specified, sets the parent id accessor to the given function and returns this stratify operator. Otherwise, returns the current parent id accessor, which defaults to: @@ -326,13 +326,21 @@ function parentId(d) { The parent id accessor is invoked for each element in the input data passed to the [stratify operator](#_stratify), being passed the current datum (*d*) and the current index (*i*). The returned string is then used to identify the node’s relationships in conjunction with the [id](#stratify_id). For the root node, the parent id should be undefined. (Null and the empty string are equivalent to undefined.) There must be exactly one root node in the input data, and no circular relationships. +# stratify.path([path]) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js), [Examples](https://observablehq.com/@d3/d3-stratify) + +If *path* is specified, sets the path accessor to the given function and returns this stratify operator. Otherwise, returns the current path accessor, which defaults to undefined. If a path accessor is set, the id and parentId arguments are ignored, and a unix-like hierarchy is computed on the slash-delimited strings returned by the path accessor, imputing parent nodes and ids as necessary. + +```js +d3.stratify().path(d => d)(["a/b", "a/c"]); // nodes with id "/a", "/a/b", "/a/c" +``` + ### Cluster -[Dendrogram](https://observablehq.com/@d3/cluster-dendrogram) +[Dendrogram](https://observablehq.com/@d3/cluster-dendrogram) The **cluster layout** produces [dendrograms](http://en.wikipedia.org/wiki/Dendrogram): node-link diagrams that place leaf nodes of the tree at the same depth. Dendrograms are typically less compact than [tidy trees](#tree), but are useful when all the leaves should be at the same level, such as for hierarchical clustering or [phylogenetic tree diagrams](https://observablehq.com/@mbostock/tree-of-life). -# d3.cluster() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/cluster.js), [Examples](https://observablehq.com/@d3/cluster-dendrogram) +# d3.cluster() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/cluster.js), [Examples](https://observablehq.com/@d3/cluster-dendrogram) Creates a new cluster layout with default settings. @@ -367,11 +375,11 @@ The separation accessor is used to separate neighboring leaves. The separation f ### Tree -[Tidy Tree](https://observablehq.com/@d3/tidy-tree) +[Tidy Tree](https://observablehq.com/@d3/tidy-tree) The **tree** layout produces tidy node-link diagrams of trees using the [Reingold–Tilford “tidy” algorithm](http://reingold.co/tidier-drawings.pdf), improved to run in linear time by [Buchheim *et al.*](http://dirk.jivas.de/papers/buchheim02improving.pdf) Tidy trees are typically more compact than [dendrograms](#cluster). -# d3.tree() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/tree.js), [Examples](https://observablehq.com/@d3/tidy-tree) +# d3.tree() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/tree.js), [Examples](https://observablehq.com/@d3/tidy-tree) Creates a new tree layout with default settings. @@ -414,11 +422,11 @@ The separation accessor is used to separate neighboring nodes. The separation fu ### Treemap -[Treemap](https://observablehq.com/@d3/treemap) +[Treemap](https://observablehq.com/@d3/treemap) Introduced by [Ben Shneiderman](http://www.cs.umd.edu/hcil/treemap-history/) in 1991, a **treemap** recursively subdivides area into rectangles according to each node’s associated value. D3’s treemap implementation supports an extensible [tiling method](#treemap_tile): the default [squarified](#treemapSquarify) method seeks to generate rectangles with a [golden](https://en.wikipedia.org/wiki/Golden_ratio) aspect ratio; this offers better readability and size estimation than [slice-and-dice](#treemapSliceDice), which simply alternates between horizontal and vertical subdivision by depth. -# d3.treemap() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/index.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemap() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/index.js), [Examples](https://observablehq.com/@d3/treemap) Creates a new treemap layout with default settings. @@ -477,41 +485,41 @@ If *padding* is specified, sets the left padding to the specified number or func Several built-in tiling methods are provided for use with [*treemap*.tile](#treemap_tile). -# d3.treemapBinary(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/binary.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemapBinary(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/binary.js), [Examples](https://observablehq.com/@d3/treemap) Recursively partitions the specified *nodes* into an approximately-balanced binary tree, choosing horizontal partitioning for wide rectangles and vertical partitioning for tall rectangles. -# d3.treemapDice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/dice.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemapDice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/dice.js), [Examples](https://observablehq.com/@d3/treemap) Divides the rectangular area specified by *x0*, *y0*, *x1*, *y1* horizontally according the value of each of the specified *node*’s children. The children are positioned in order, starting with the left edge (*x0*) of the given rectangle. If the sum of the children’s values is less than the specified *node*’s value (*i.e.*, if the specified *node* has a non-zero internal value), the remaining empty space will be positioned on the right edge (*x1*) of the given rectangle. -# d3.treemapSlice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/slice.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemapSlice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/slice.js), [Examples](https://observablehq.com/@d3/treemap) Divides the rectangular area specified by *x0*, *y0*, *x1*, *y1* vertically according the value of each of the specified *node*’s children. The children are positioned in order, starting with the top edge (*y0*) of the given rectangle. If the sum of the children’s values is less than the specified *node*’s value (*i.e.*, if the specified *node* has a non-zero internal value), the remaining empty space will be positioned on the bottom edge (*y1*) of the given rectangle. -# d3.treemapSliceDice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/sliceDice.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemapSliceDice(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/sliceDice.js), [Examples](https://observablehq.com/@d3/treemap) If the specified *node* has odd depth, delegates to [treemapSlice](#treemapSlice); otherwise delegates to [treemapDice](#treemapDice). -# d3.treemapSquarify(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/squarify.js), [Examples](https://observablehq.com/@d3/treemap) +# d3.treemapSquarify(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/squarify.js), [Examples](https://observablehq.com/@d3/treemap) Implements the [squarified treemap](https://www.win.tue.nl/~vanwijk/stm.pdf) algorithm by Bruls *et al.*, which seeks to produce rectangles of a given [aspect ratio](#squarify_ratio). -# d3.treemapResquarify(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/resquarify.js), [Examples](https://observablehq.com/@d3/animated-treemap) +# d3.treemapResquarify(node, x0, y0, x1, y1) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/resquarify.js), [Examples](https://observablehq.com/@d3/animated-treemap) Like [d3.treemapSquarify](#treemapSquarify), except preserves the topology (node adjacencies) of the previous layout computed by d3.treemapResquarify, if there is one and it used the same [target aspect ratio](#squarify_ratio). This tiling method is good for animating changes to treemaps because it only changes node sizes and not their relative positions, thus avoiding distracting shuffling and occlusion. The downside of a stable update, however, is a suboptimal layout for subsequent updates: only the first layout uses the Bruls *et al.* squarified algorithm. -# squarify.ratio(ratio) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/treemap/squarify.js), [Examples](https://observablehq.com/@d3/treemap) +# squarify.ratio(ratio) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/treemap/squarify.js), [Examples](https://observablehq.com/@d3/treemap) Specifies the desired aspect ratio of the generated rectangles. The *ratio* must be specified as a number greater than or equal to one. Note that the orientation of the generated rectangles (tall or wide) is not implied by the ratio; for example, a ratio of two will attempt to produce a mixture of rectangles whose *width*:*height* ratio is either 2:1 or 1:2. (However, you can approximately achieve this result by generating a square treemap at different dimensions, and then [stretching the treemap](https://observablehq.com/@d3/stretched-treemap) to the desired aspect ratio.) Furthermore, the specified *ratio* is merely a hint to the tiling algorithm; the rectangles are not guaranteed to have the specified aspect ratio. If not specified, the aspect ratio defaults to the golden ratio, φ = (1 + sqrt(5)) / 2, per [Kong *et al.*](http://vis.stanford.edu/papers/perception-treemaps) ### Partition -[Partition](https://observablehq.com/@d3/icicle) +[Partition](https://observablehq.com/@d3/icicle) The **partition layout** produces adjacency diagrams: a space-filling variant of a node-link tree diagram. Rather than drawing a link between parent and child in the hierarchy, nodes are drawn as solid areas (either arcs or rectangles), and their placement relative to other nodes reveals their position in the hierarchy. The size of the nodes encodes a quantitative dimension that would be difficult to show in a node-link diagram. -# d3.partition() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/partition.js), [Examples](https://observablehq.com/@d3/icicle) +# d3.partition() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/partition.js), [Examples](https://observablehq.com/@d3/icicle) Creates a new partition layout with the default settings. @@ -540,11 +548,11 @@ If *padding* is specified, sets the padding to the specified number and returns ### Pack -[Circle-Packing](https://observablehq.com/@d3/circle-packing) +[Circle-Packing](https://observablehq.com/@d3/circle-packing) Enclosure diagrams use containment (nesting) to represent a hierarchy. The size of the leaf circles encodes a quantitative dimension of the data. The enclosing circles show the approximate cumulative size of each subtree, but due to wasted space there is some distortion; only the leaf nodes can be compared accurately. Although [circle packing](http://en.wikipedia.org/wiki/Circle_packing) does not use space as efficiently as a [treemap](#treemap), the “wasted” space more prominently reveals the hierarchical structure. -# d3.pack() · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/pack/index.js), [Examples](https://observablehq.com/@d3/circle-packing) +# d3.pack() · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/pack/index.js), [Examples](https://observablehq.com/@d3/circle-packing) Creates a new pack layout with the default settings. @@ -570,7 +578,7 @@ If *size* is specified, sets this pack layout’s size to the specified two-elem If *padding* is specified, sets this pack layout’s padding accessor to the specified number or function and returns this pack layout. If *padding* is not specified, returns the current padding accessor, which defaults to the constant zero. When siblings are packed, tangent siblings will be separated by approximately the specified padding; the enclosing parent circle will also be separated from its children by approximately the specified padding. If an [explicit radius](#pack_radius) is not specified, the padding is approximate because a two-pass algorithm is needed to fit within the [layout size](#pack_size): the circles are first packed without padding; a scaling factor is computed and applied to the specified padding; and lastly the circles are re-packed with padding. -# d3.packSiblings(circles) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/pack/siblings.js) +# d3.packSiblings(circles) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/pack/siblings.js) Packs the specified array of *circles*, each of which must have a *circle*.r property specifying the circle’s radius. Assigns the following properties to each circle: @@ -579,6 +587,6 @@ Packs the specified array of *circles*, each of which must have a *circle*.r pro The circles are positioned according to the front-chain packing algorithm by [Wang *et al.*](https://dl.acm.org/citation.cfm?id=1124851) -# d3.packEnclose(circles) · [Source](https://github.com/d3/d3-hierarchy/blob/master/src/pack/enclose.js), [Examples](https://observablehq.com/@d3/d3-packenclose) +# d3.packEnclose(circles) · [Source](https://github.com/d3/d3-hierarchy/blob/main/src/pack/enclose.js), [Examples](https://observablehq.com/@d3/d3-packenclose) Computes the [smallest circle](https://en.wikipedia.org/wiki/Smallest-circle_problem) that encloses the specified array of *circles*, each of which must have a *circle*.r property specifying the circle’s radius, and *circle*.x and *circle*.y properties specifying the circle’s center. The enclosing circle is computed using the [Matoušek-Sharir-Welzl algorithm](http://www.inf.ethz.ch/personal/emo/PublFiles/SubexLinProg_ALG16_96.pdf). (See also [Apollonius’ Problem](https://bl.ocks.org/mbostock/751fdd637f4bc2e3f08b).) diff --git a/src/stratify.js b/src/stratify.js index a6676c25..f8fd0e16 100644 --- a/src/stratify.js +++ b/src/stratify.js @@ -1,8 +1,9 @@ -import {required} from "./accessors.js"; +import {optional} from "./accessors.js"; import {Node, computeHeight} from "./hierarchy/index.js"; var preroot = {depth: -1}, - ambiguous = {}; + ambiguous = {}, + imputed = {}; function defaultId(d) { return d.id; @@ -14,11 +15,14 @@ function defaultParentId(d) { export default function() { var id = defaultId, - parentId = defaultParentId; + parentId = defaultParentId, + path; function stratify(data) { var nodes = Array.from(data), - n = nodes.length, + currentId = id, + currentParentId = parentId, + n, d, i, root, @@ -28,13 +32,29 @@ export default function() { nodeKey, nodeByKey = new Map; - for (i = 0; i < n; ++i) { + if (path != null) { + const I = nodes.map((d, i) => normalize(path(d, i, data))); + const P = I.map(parentof); + const S = new Set(I).add(""); + for (const i of P) { + if (!S.has(i)) { + S.add(i); + I.push(i); + P.push(parentof(i)); + nodes.push(imputed); + } + } + currentId = (_, i) => I[i]; + currentParentId = (_, i) => P[i]; + } + + for (i = 0, n = nodes.length; i < n; ++i) { d = nodes[i], node = nodes[i] = new Node(d); - if ((nodeId = id(d, i, data)) != null && (nodeId += "")) { + if ((nodeId = currentId(d, i, data)) != null && (nodeId += "")) { nodeKey = node.id = nodeId; nodeByKey.set(nodeKey, nodeByKey.has(nodeKey) ? ambiguous : node); } - if ((nodeId = parentId(d, i, data)) != null && (nodeId += "")) { + if ((nodeId = currentParentId(d, i, data)) != null && (nodeId += "")) { node.parent = nodeId; } } @@ -55,6 +75,20 @@ export default function() { } if (!root) throw new Error("no root"); + + // When imputing internal nodes, only introduce roots if needed. + // Then replace the imputed marker data with null. + if (path != null) { + while (root.data === imputed && root.children.length === 1) { + root = root.children[0], --n; + } + for (let i = nodes.length - 1; i >= 0; --i) { + node = nodes[i]; + if (node.data !== imputed) break; + node.data = null; + } + } + root.parent = preroot; root.eachBefore(function(node) { node.depth = node.parent.depth + 1; --n; }).eachBefore(computeHeight); root.parent = null; @@ -64,12 +98,48 @@ export default function() { } stratify.id = function(x) { - return arguments.length ? (id = required(x), stratify) : id; + return arguments.length ? (id = optional(x), stratify) : id; }; stratify.parentId = function(x) { - return arguments.length ? (parentId = required(x), stratify) : parentId; + return arguments.length ? (parentId = optional(x), stratify) : parentId; + }; + + stratify.path = function(x) { + return arguments.length ? (path = optional(x), stratify) : path; }; return stratify; } + +// To normalize a path, we coerce to a string, strip trailing slash if present, +// and add leading slash if missing. This requires counting the number of +// preceding backslashes which may be used to escape the forward slash: an odd +// number indicates an escaped forward slash. +function normalize(path) { + path = `${path}`; + let i = path.length - 1; + if (path[i] === "/") { + let k = 0; + while (i > 0 && path[--i] === "\\") ++k; + if ((k & 1) === 0) path = path.slice(0, -1); + } + return path[0] === "/" ? path : `/${path}`; +} + +// Walk backwards to find the first slash that is not the leading slash, e.g.: +// "/foo/bar" ⇥ "/foo", "/foo" ⇥ "/", "/" ↦ "". (The root is special-cased +// because the id of the root must be a truthy value.) The slash may be escaped, +// which again requires counting the number of preceding backslashes. Note that +// normalized paths cannot end with a slash except for the root. +function parentof(path) { + let i = path.length; + while (i > 2) { + if (path[--i] === "/") { + let j = i, k = 0; + while (j > 0 && path[--j] === "\\") ++k; + if ((k & 1) === 0) break; + } + } + return path.slice(0, i < 3 ? i - 1 : i); +} diff --git a/test/stratify-test.js b/test/stratify-test.js index 2e1d3603..efaabda1 100644 --- a/test/stratify-test.js +++ b/test/stratify-test.js @@ -377,7 +377,7 @@ it("stratify.id(id) observes the specified id function", () => { it("stratify.id(id) tests that id is a function", () => { const s = stratify(); assert.throws(() => void s.id(42)); - assert.throws(() => void s.id(null)); + assert.throws(() => void s.id("nope")); }); it("stratify.parentId(id) observes the specified parent id function", () => { @@ -423,7 +423,431 @@ it("stratify.parentId(id) observes the specified parent id function", () => { it("stratify.parentId(id) tests that id is a function", () => { const s = stratify(); assert.throws(() => void s.parentId(42)); - assert.throws(() => void s.parentId(null)); + assert.throws(() => void s.parentId("nope")); +}); + +it("stratify.path(path) returns the root node", () => { + const root = stratify().path(d => d.path)([ + {path: "/"}, + {path: "/aa"}, + {path: "/ab"}, + {path: "/aa/aaa"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: {path: "/"}, + children: [ + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + }, + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + } + ] + }); +}); + +it("stratify.path(path) allows slashes to be escaped", () => { + const root = stratify().path(d => d.path)([ + {path: "/"}, + {path: "/aa"}, + {path: "\\/ab"}, + {path: "/aa\\/aaa"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 1, + data: {path: "/"}, + children: [ + { + id: "/aa", + depth: 1, + height: 0, + data: {path: "/aa"} + }, + { + id: "/\\/ab", + depth: 1, + height: 0, + data: {path: "\\/ab"} + }, + { + id: "/aa\\/aaa", + depth: 1, + height: 0, + data: {path: "/aa\\/aaa"} + } + ] + }); +}); + +it("stratify.path(path) imputes internal nodes", () => { + const root = stratify().path(d => d.path)([ + {path: "/aa/aaa"}, + {path: "/ab"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + }, + { + id: "/aa", + depth: 1, + height: 1, + data: null, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + } + ] + }); +}); + +it("stratify.path(path) allows duplicate leaf paths", () => { + const root = stratify().path(d => d.path)([ + {path: "/aa/aaa", number: 1}, + {path: "/aa/aaa", number: 2}, + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/aa", + depth: 0, + height: 1, + data: null, + children: [ + { + id: "/aa/aaa", + depth: 1, + height: 0, + data: {path: "/aa/aaa", number: 1} + }, + { + id: "/aa/aaa", + depth: 1, + height: 0, + data: {path: "/aa/aaa", number: 2} + } + ] + }); +}); + +it("stratify.path(path) does not allow duplicate internal paths", () => { + assert.throws(() => { + stratify().path(d => d.path)([ + {path: "/aa"}, + {path: "/aa"}, + {path: "/aa/aaa"}, + {path: "/aa/aaa"}, + ]); + }, /ambiguous/); +}); + +it("stratify.path(path) implicitly adds leading slashes", () => { + const root = stratify().path(d => d.path)([ + {path: ""}, + {path: "aa"}, + {path: "ab"}, + {path: "aa/aaa"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: {path: ""}, + children: [ + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "aa/aaa"} + } + ] + }, + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "ab"} + } + ] + }); +}); + +it("stratify.path(path) implicitly trims trailing slashes", () => { + const root = stratify().path(d => d.path)([ + {path: "/aa/"}, + {path: "/ab/"}, + {path: "/aa/aaa/"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa/"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa/"} + } + ] + }, + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab/"} + } + ] + }); +}); + +it("stratify.path(path) trims at most one trailing slash", () => { + const root = stratify().path(d => d.path)([ + {path: "/aa///"}, + {path: "/b"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 3, + data: null, + children: [ + { + id: "/b", + depth: 1, + height: 0, + data: {path: "/b"} + }, + { + id: "/aa", + depth: 1, + height: 2, + data: null, + children: [ + { + id: "/aa/", + depth: 2, + height: 1, + data: null, + children: [ + { + id: "/aa//", + depth: 3, + height: 0, + data: {path: "/aa///"}, + } + ] + } + ] + } + ] + }); +}); + +it("stratify.path(path) does not require the data to be in topological order", () => { + const root = stratify().path(d => d.path)([ + {path: "/aa/aaa"}, + {path: "/aa"}, + {path: "/ab"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + }, + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + } + ] + }); +}); + +it("stratify.path(path) preserves the input order of siblings", () => { + const root = stratify().path(d => d.path)([ + {path: "/ab"}, + {path: "/aa"}, + {path: "/aa/aaa"} + ]); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + }, + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + } + ] + }); +}); + +it("stratify.path(path) accepts an iterable", () => { + const root = stratify().path(d => d.path)(new Set([ + {path: "/ab"}, + {path: "/aa"}, + {path: "/aa/aaa"} + ])); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + }, + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + } + ] + }); +}); + +it("stratify.path(path) coerces paths to strings", () => { + class Path { + constructor(path) { + this.path = path; + } + toString() { + return this.path; + } + } + const root = stratify().path(d => d.path)([ + {path: "/ab"}, + {path: "/aa"}, + {path: "/aa/aaa"} + ], d => new Path(d.path)); + assert(root instanceof hierarchy); + assert.deepStrictEqual(noparent(root), { + id: "/", + depth: 0, + height: 2, + data: null, + children: [ + { + id: "/ab", + depth: 1, + height: 0, + data: {path: "/ab"} + }, + { + id: "/aa", + depth: 1, + height: 1, + data: {path: "/aa"}, + children: [ + { + id: "/aa/aaa", + depth: 2, + height: 0, + data: {path: "/aa/aaa"} + } + ] + } + ] + }); }); function noparent(node) {