enclose and the Shape family
enclose draws an outline around a set of nodes, under them. The outline is a value of the Shape strategy family: the padded bounding box (Shape.box()), the minimum-area enclosing ellipse (Shape.oval()), or a smooth region (Shape.blob() and its two presets, Shape.hull() and Shape.spine()). This essay explains how the node is built, how an oval's and a blob's outlines are computed, and which decisions and shortcuts the design rests on. The user-facing reference is enclose and Shape.
A relational mark
enclose is built with createRelationalMark, beside line and ribbon (marks/chart.ts; see The Mark Factory). So it has their forms for free:
- the combinator form
enclose(opts, children), over fresh children,ref(...)children, or the operands of a.relate()clause; - the bag form
chart(selectAll(name)).mark(enclose(opts)), where aflow(group({ by }))draws one outline per group andfillorstrokemay name a field that is constant within the group.
createRelationalMark hands produce the members, and encloseNode (graphicalOperators/enclose.tsx) builds the node. Its children are the members followed by one fresh ref per avoided node. An avoided ref draws nothing; it is a child only so that it is resolved and laid out like any ref, which gives its box in the enclosure's frame. Only the members count toward the enclosure's own box.
Layout places a fresh member the way layer places a child (placeUnplacedChild), and leaves a ref where its target is. The box shape uses the union of the members' boxes grown by padding. An oval or a blob computes its outline in the same pass, in the frame the children's boxes were read in, and the node's box is the outline's box. Both are kept in renderData, and lower maps them through local (the node's translate and y direction) and toPixel. A blob is one SVG path over all its loops. Its outer loops and its holes wind opposite ways, so the default nonzero fill rule leaves the holes open, with no change to the display list.
The outline paints under its members. Among a layer's children that is the default zBelow every relational mark gets; as a .relate() clause it is the same default installed by name (see Names and Scoping).
Reading members
members.ts reads a placed node as outline geometry, in the enclosure's frame:
- A circle (a node of type
ellipsewith equal sides) is a disc. - An ellipse that is not a circle is the disc of its larger radius, carrying its two semi-axes as well. The blob reads only the disc, so it pads the ellipse a little more along its short axis (a declared shortcut, below). The oval reads the semi-axes and samples the ellipse itself.
- A
lineis its segments, each a tube as wide as its stroke. The connector records its stroke width inrenderData.centerline; aribbon, whose paths are a band's outline, does not, so it is read as a box. - Anything else is its bounding box. The blob measures the distance to it exactly, as a rectangle.
This reader is internal. There is no members option and no public geometry protocol for it yet.
How an oval is built
oval.ts is pure geometry too: ovalOutline(members, padding) returns one loop.
- Discs. Each member grown by
paddingis held by the convex hull of a few discs: a disc grown bypadding; a box's four corners, each a disc of radiuspadding; a segment's two ends, each a disc of half its width pluspadding; an ellipse that is not a circle, the vertices of the 64-gon drawn around it, each a disc of radiuspadding. For a disc, a box and a segment the hull is the padded member itself, so an ellipse holds the member exactly when it holds these discs. An ellipse grown bypaddingis not an ellipse (its ends are blunter), and its hull of discs is a little larger than it: by at most 0.12% of the ellipse's radius, plus the 24-gons' 0.86% ofpadding(step 2). - Samples. Each disc is sampled as the 24-gon drawn around it (its vertices at
r / cos(π / 24)), so the samples' convex hull holds every padded member. Only that hull goes on. - Khachiyan's algorithm. It finds the minimum-area ellipse around the hull's points to a tolerance of 1e-4, after moving the points to their mean so the 3 × 3 system stays well conditioned. Its answer can leave a point outside by up to the tolerance, so the ellipse is scaled about its center until every point is inside.
- Outline. The loop is the 128-gon drawn around the ellipse (the image of the 128-gon around the unit circle), so the path holds the ellipse too. The ellipse may be rotated, and the loop is drawn like a blob's: one path, through
localandtoPixel, so it is right in a y-up frame.
If every sample lies on or within a hair of one line (members with no size and no padding), the least ellipse is flat and its matrix cannot be inverted. The hull counts as flat when its area is under 1e-6 of its extent's diagonal squared, a test with no scale in it; the outline is then the hull itself, a sliver or a segment, and nothing for a single point. src/tests/enclose.test.ts checks that every padded dot, box and segment of a mixed set is inside the ellipse and its loop, that points along a diagonal tilt it to 45°, that one member gives a circle, that members on a line give a thin ellipse with no NaN, that zero-size members a hair off one line give their hull with no NaN, and that the three penguin species take well under 100 ms.
The ellipse is computed from the members, not fit to their bounding box. Fitting was prototyped while the shape was being designed: an ellipse inscribed in the padded box clips the members near its corners, and one circumscribed through the corners (TikZ's fit) is √2 times the box on each axis, so it balloons well past the members and over a chart's axes. Only the minimum-area ellipse both holds the members and stays close to them. Using any mark as the outline is left to #1167.
How a blob is built
blob.ts is pure geometry: blobOutline(members, opts) returns the outline's loops. It runs in screen pixels, after layout, so it works the same way under any coordinate transform.
- Sites and triangles. Each member contributes its sites: a disc its center, a segment its two ends. The sites are triangulated (Delaunay, by
d3-delaunay). - Convexity as a share of the hull. The triangles are taken in order of circumradius, the radius of their empty circle, which is how wide a gap the triangle spans. A triangle is filled when the midpoint of its area, along the running total, is within
convexityof the hull's area. Triangles with the same circumradius count together. So 0 fills nothing (the spine), 1 fills every triangle (the hull), and the filled share followsconvexityto within one triangle, at any chart size. - Links. The links are the edges of the filled triangles, plus the minimum spanning tree over the members, so the set is always one piece. A tree link runs between the closest points of two members, so members that touch join at no cost. It is routed around the avoided nodes: a visibility graph over their keep-out polygons, searched with A*, then snapped onto the keep-out circles, so a route is straight lines tangent to the circles and arcs of them. Every link is chosen at full width, so
necknever changes which links there are. The tree's candidates are the Delaunay edges, plus, for a segment member, every other member, since its closest point may lie anywhere along it. Those pairs join the queue a distance band at a time (touching pairs, then 16 px, doubling), found through a grid over the members, and only while the set is in more than one piece, so a 1,000-point line does not pair every segment with every member. Each keeps the place in the order it would have had in the full list, so the tree is the same. - Region. The pieces are each member grown by
padding, each link as a tube that tapers from the bulge at one end to the bulge at the other (the convex hull of the two end discs, a tapered capsule with a closed-form distance; with equal ends it is the plain capsule), and the filled triangles. A routed link tapers along its length, and an arc of it around an avoided node keeps the larger of its two ends' half-widths. A link is routed at its larger half-width, so the narrower part keeps more than the clearance. Each avoided node grown byclearanceis a keep-out zone. The region is the union of the pieces minus the union of the zones. - Exact trace. Every piece and zone has a closed-form signed distance (a disc, a capsule, a box, a triangle, or a tube bent around an arc). A quadtree over a 1 px grid keeps, per cell, only the primitives that can be nearest there, marching squares joins the cells the outline crosses into loops, and each crossing is found to a ten-thousandth of a pixel. Each traced point records the circle it lies on, if any, and whether it is on a member's bulge.
- Holes. With
holes: false(the default, as in PostGIS and Shapely), every hole of the traced region is filled, and the keep-out zones it held are traced again on their own and put back as holes. So an avoided node inside the set is always exactly its clearance circle. A hole made only of keep-out circles stays as it is. - Neck. Every outline point not on a member's bulge moves inward by
1 - necktimes the region's half-width there (the largest disc inside the region that touches the outline at that point, capped at the link's own half-width), along its own piece's normal. Where two pieces meet in a corner the moved sides are carried on until they meet, so the pull never rounds anything. A point that would move into a bulge stops on its edge. Small knots where the pull changes quickly are cut out, and where two parts of the outline would still cross, the pull is halved near that crossing only. - Roundness. The outline is smoothed along its own length by a Gaussian of width
roundness / 2. Smoothing pulls a circle of radius r inward by a known factor, so each point on a circle is first moved out by that factor: circles and straight sides come back where they were, and only corners round. Inlets narrower than 1.5 σ are closed first, and points near a keep-out zone are pushed out by a smooth maximum, so the outline never enters a zone. Rounding is capped at the smallest circle in the region. If two loops would cross, σ is narrowed by bisection until they do not.
convexity alone decides which gaps fill, so it decides the pieces and the holes. roundness changes only curvature and neck only how far the outline pulls in; neither changes the pieces or the holes. src/tests/enclose.test.ts checks this over sweeps of both on two point sets, and checks that the hull preset is within half a pixel of the true padded convex hull.
The Shape family
families/shape.ts is a strategy family like Curve or Overlap. Each member returns a plain object, { kind, ...params }. Shape.oval() has no params: the minimum-area ellipse is one fixed construction, and padding and the paint are already on enclose. Shape.hull() and Shape.spine() are presets of Shape.blob(), declared in the STRATEGIES table the way sina() and jitter() are presets of noise(), so they make kind "blob". The wire form is the same object, except a blob's avoiding, which holds nodes in JavaScript and their references on the wire (NodeSelectionIR; see Frontend IR).
Decisions and declared shortcuts
Settled:
- One construct,
enclose. The oldbackgroundalias is deleted, and the corner radii moved fromenclosetoShape.box({ rx, ry }). paddingand the paint stay onenclose; the shape's own options stay on the shape.Shape.oval()is the minimum-area enclosing ellipse, not a mark drawn in the bounding box (see above). Nocurveoption, no plot-area rule.avoidingtakes a list of nodes or refs, or oneselectAll(name)as the whole value, in JS as on the wire (NodeSelectionIR): a single ref and aselectAllinside a list are errors. A listed node that is also a member is ignored, so the user never computes a set difference.neckis above 0 and at most 1. At 0 a link would have no width: its two pulled-in sides meet, the crossing check gives the pull back, and the outline comes out fuller than at a smallneck. Any value above 0 is honored as asked (there is no hidden floor).
Shortcuts, each a known approximation:
- In a blob, an ellipse that is not a circle is read as its enclosing circle (its larger radius), so it gets a little more padding along its short axis. The oval samples the ellipse itself.
- A curved
linepiece (a Bezier segment) is cut into 8 straight segments. - A segment member keeps its own padding band at every
convexity: a filled triangle's side along a member edge is that edge's band, and only the enclosure's own links thin underneck. - An avoided segment's keep-out is routed as a capsule polygon whose ends are plain corners, not snapped to arcs.
- There is no
membersoption and no list-takingresolveyet; members are the children or the refs of the chart's data. - The paths of a
linemember are assumed to share the axis order of its placed box; this has only been exercised in canvas-oriented frames.
