Skip to content

Commit

Permalink
spike vector shape + centroid transforms (#1189)
Browse files Browse the repository at this point in the history
* spike, geoCentroid

* vector shape, template

* planar centroid

* document vector and spike

* document centroid and geoCentroid

* r instead of width

* Update README

* geometry option; update README

* lift size

* fancy splice

* projected centroid via initializer

* Update README

* Update README

* geoCentroid

* geoCentroid is faster, actually

* test centroids

Co-authored-by: Philippe Rivière <[email protected]>
  • Loading branch information
mbostock and Fil authored Dec 20, 2022
1 parent 5928263 commit b31b8cc
Show file tree
Hide file tree
Showing 20 changed files with 10,581 additions and 4,390 deletions.
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1749,11 +1749,19 @@ In addition to the [standard mark options](#marks), the following optional chann
If either of the **x** or **y** channels are not specified, the corresponding position is controlled by the **frameAnchor** option.
The following options are also supported:
The following constant options are also supported:
* **shape** - the shape of the vector; defaults to *arrow*
* **r** - a radius in pixels; defaults to 3.5
* **anchor** - one of *start*, *middle*, or *end*; defaults to *middle*
* **frameAnchor** - the [frame anchor](#frameanchor); defaults to *middle*
The **shape** option controls the visual appearance (path geometry) of the vector and supports the following values:
* *arrow* (default) - an arrow with head size proportional to its length
* *spike* - an isosceles triangle with open base
* any object with a **draw** method; it receives a *context*, *length*, and *radius*
If the **anchor** is *start*, the arrow will start at the given *xy* position and point in the direction given by the rotation angle. If the **anchor** is *end*, the arrow will maintain the same orientation, but be positioned such that it ends in the given *xy* position. If the **anchor** is *middle*, the arrow will be likewise be positioned such that its midpoint intersects the given *xy* position.
If the **x** channel is not specified, vectors will be horizontally centered in the plot (or facet). Likewise if the **y** channel is not specified, vectors will be vertically centered in the plot (or facet). Typically either *x*, *y*, or both are specified.
Expand Down Expand Up @@ -1792,6 +1800,14 @@ Equivalent to [Plot.vector](#plotvectordata-options) except that if the **y** op
<!-- jsdocEnd vectorY -->
#### Plot.spike(*data*, *options*)
<!-- jsdoc spike -->
Equivalent to [Plot.vector](#plotvectordata-options) except that the **shape** defaults to *spike*, the **stroke** defaults to *currentColor*, the **strokeWidth** defaults to 1, the **fill** defaults to **stroke**, the **fillOpacity** defaults to 0.3, and the **anchor** defaults to *start*.
<!-- jsdocEnd spike -->
## Decorations
Decorations are static marks that do not represent data. Currently this includes only [Plot.frame](#frame), although internally Plot’s axes are implemented as decorations and may in the future be exposed here for more flexible configuration.
Expand Down Expand Up @@ -2064,6 +2080,32 @@ Bins on *y*. Also groups on *x* and first channel of *z*, *fill*, or *stroke*, i
<!-- jsdocEnd binY -->
### Centroid
#### Plot.centroid(*options*)
<!-- jsdoc centroid -->
The centroid initializer derives **x** and **y** channels representing the planar (projected) centroids for the the given GeoJSON geometry. If the **geometry** option is not specified, the mark’s data is assumed to be GeoJSON objects.
```js
Plot.dot(regions.features, Plot.centroid()).plot({projection: "reflect-y"})
```
<!-- jsdocEnd centroid -->
#### Plot.geoCentroid(*options*)
<!-- jsdoc geoCentroid -->
The geoCentroid transform derives **x** and **y** channels representing the spherical centroids for the the given GeoJSON geometry. If the **geometry** option is not specified, the mark’s data is assumed to be GeoJSON objects.
```js
Plot.dot(counties.features, Plot.geoCentroid()).plot({projection: "albers-usa"})
```
<!-- jsdocEnd geoCentroid -->
### Group
[<img src="./img/group.png" width="320" height="198" alt="a histogram of penguins by species">](https://observablehq.com/@observablehq/plot-group)
Expand Down
3 changes: 2 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ 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 {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
export {valueof, column} from "./options.js";
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
export {centroid, geoCentroid} from "./transforms/centroid.js";
export {dodgeX, dodgeY} from "./transforms/dodge.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
export {hexbin} from "./transforms/hexbin.js";
Expand Down
52 changes: 32 additions & 20 deletions src/marks/dot.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
applyTransform
} from "../style.js";
import {maybeSymbolChannel} from "../symbols.js";
import {template} from "../template.js";
import {sort} from "../transforms/basic.js";
import {maybeIntervalMidX, maybeIntervalMidY} from "../transforms/interval.js";

Expand Down Expand Up @@ -66,9 +67,10 @@ export class Dot extends Mark {
render(index, scales, channels, dimensions, context) {
const {x, y} = scales;
const {x: X, y: Y, r: R, rotate: A, symbol: S} = channels;
const {r, rotate, symbol} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const circle = this.symbol === symbolCircle;
const {r} = this;
const size = R ? undefined : r * r * Math.PI;
if (negative(r)) index = [];
return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions, context)
Expand All @@ -89,29 +91,39 @@ export class Dot extends Mark {
.attr("r", R ? (i) => R[i] : r);
}
: (selection) => {
const translate =
X && Y
? (i) => `translate(${X[i]},${Y[i]})`
: X
? (i) => `translate(${X[i]},${cy})`
: Y
? (i) => `translate(${cx},${Y[i]})`
: () => `translate(${cx},${cy})`;
selection
.attr(
"transform",
A
? (i) => `${translate(i)} rotate(${A[i]})`
: this.rotate
? (i) => `${translate(i)} rotate(${this.rotate})`
: translate
template`translate(${X ? (i) => X[i] : cx},${Y ? (i) => Y[i] : cy})${
A ? (i) => ` rotate(${A[i]})` : rotate ? ` rotate(${rotate})` : ``
}`
)
.attr("d", (i) => {
const p = path(),
radius = R ? R[i] : r;
(S ? S[i] : this.symbol).draw(p, radius * radius * Math.PI);
return p;
});
.attr(
"d",
R && S
? (i) => {
const p = path();
S[i].draw(p, R[i] * R[i] * Math.PI);
return p;
}
: R
? (i) => {
const p = path();
symbol.draw(p, R[i] * R[i] * Math.PI);
return p;
}
: S
? (i) => {
const p = path();
S[i].draw(p, size);
return p;
}
: (() => {
const p = path();
symbol.draw(p, size);
return p;
})()
);
}
)
.call(applyChannelStyles, this, channels)
Expand Down
27 changes: 4 additions & 23 deletions src/marks/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
impliedString,
applyFrameAnchor
} from "../style.js";
import {template} from "../template.js";
import {maybeIntervalMidX, maybeIntervalMidY} from "../transforms/interval.js";

const defaults = {
Expand Down Expand Up @@ -100,29 +101,9 @@ export class Text extends Mark {
.call(applyMultilineText, this, T)
.attr(
"transform",
R
? X && Y
? (i) => `translate(${X[i]},${Y[i]}) rotate(${R[i]})`
: X
? (i) => `translate(${X[i]},${cy}) rotate(${R[i]})`
: Y
? (i) => `translate(${cx},${Y[i]}) rotate(${R[i]})`
: (i) => `translate(${cx},${cy}) rotate(${R[i]})`
: rotate
? X && Y
? (i) => `translate(${X[i]},${Y[i]}) rotate(${rotate})`
: X
? (i) => `translate(${X[i]},${cy}) rotate(${rotate})`
: Y
? (i) => `translate(${cx},${Y[i]}) rotate(${rotate})`
: `translate(${cx},${cy}) rotate(${rotate})`
: X && Y
? (i) => `translate(${X[i]},${Y[i]})`
: X
? (i) => `translate(${X[i]},${cy})`
: Y
? (i) => `translate(${cx},${Y[i]})`
: `translate(${cx},${cy})`
template`translate(${X ? (i) => X[i] : cx},${Y ? (i) => Y[i] : cy})${
R ? (i) => ` rotate(${R[i]})` : rotate ? ` rotate(${rotate})` : ``
}`
)
.call(applyAttr, "font-size", FS && ((i) => FS[i]))
.call(applyChannelStyles, this, channels)
Expand Down
128 changes: 102 additions & 26 deletions src/marks/vector.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {path} from "d3";
import {create} from "../context.js";
import {radians} from "../math.js";
import {maybeFrameAnchor, maybeNumberChannel, maybeTuple, keyword, identity} from "../options.js";
import {Mark} from "../plot.js";
import {
Expand All @@ -9,18 +9,63 @@ import {
applyIndirectStyles,
applyTransform
} from "../style.js";
import {template} from "../template.js";

const defaults = {
ariaLabel: "vector",
fill: null,
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinejoin: "round",
strokeLinecap: "round"
};

const defaultRadius = 3.5;

// The size of the arrowhead is proportional to its length, but we still allow
// the relative size of the head to be controlled via the mark’s width option;
// doubling the default radius will produce an arrowhead that is twice as big.
// That said, we’ll probably want a arrow with a fixed head size, too.
const wingRatio = defaultRadius * 5;

const shapeArrow = {
draw(context, l, r) {
const wing = (l * r) / wingRatio;
context.moveTo(0, 0);
context.lineTo(0, -l);
context.moveTo(-wing, wing - l);
context.lineTo(0, -l);
context.lineTo(wing, wing - l);
}
};

const shapeSpike = {
draw(context, l, r) {
context.moveTo(-r, 0);
context.lineTo(0, -l);
context.lineTo(r, 0);
}
};

const shapes = new Map([
["arrow", shapeArrow],
["spike", shapeSpike]
]);

function isShapeObject(value) {
return value && typeof value.draw === "function";
}

function Shape(shape) {
if (isShapeObject(shape)) return shape;
const value = shapes.get(`${shape}`.toLowerCase());
if (value) return value;
throw new Error(`invalid shape: ${shape}`);
}

export class Vector extends Mark {
constructor(data, options = {}) {
const {x, y, length, rotate, anchor = "middle", frameAnchor} = options;
const {x, y, r = defaultRadius, length, rotate, shape = shapeArrow, anchor = "middle", frameAnchor} = options;
const [vl, cl] = maybeNumberChannel(length, 12);
const [vr, cr] = maybeNumberChannel(rotate, 0);
super(
Expand All @@ -34,23 +79,19 @@ export class Vector extends Mark {
options,
defaults
);
this.r = +r;
this.length = cl;
this.rotate = cr;
this.shape = Shape(shape);
this.anchor = keyword(anchor, "anchor", ["start", "middle", "end"]);
this.frameAnchor = maybeFrameAnchor(frameAnchor);
}
render(index, scales, channels, dimensions, context) {
const {x, y} = scales;
const {x: X, y: Y, length: L, rotate: R} = channels;
const {length, rotate, anchor} = this;
const {x: X, y: Y, length: L, rotate: A} = channels;
const {length, rotate, anchor, shape, r} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const fl = L ? (i) => L[i] : () => length;
const fr = R ? (i) => R[i] : () => rotate;
const fx = X ? (i) => X[i] : () => cx;
const fy = Y ? (i) => Y[i] : () => cy;
const k = anchor === "start" ? 0 : anchor === "end" ? 1 : 0.5;
return create("svg:g", context)
.attr("fill", "none")
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, {x: X && x, y: Y && y})
.call((g) =>
Expand All @@ -60,15 +101,36 @@ export class Vector extends Mark {
.enter()
.append("path")
.call(applyDirectStyles, this)
.attr("d", (i) => {
const l = fl(i),
a = fr(i) * radians;
const x = Math.sin(a) * l,
y = -Math.cos(a) * l;
const d = (x + y) / 5,
e = (x - y) / 5;
return `M${fx(i) - x * k},${fy(i) - y * k}l${x},${y}m${-e},${-d}l${e},${d}l${-d},${e}`;
})
.attr(
"transform",
template`translate(${X ? (i) => X[i] : cx},${Y ? (i) => Y[i] : cy})${
A ? (i) => ` rotate(${A[i]})` : rotate ? ` rotate(${rotate})` : ``
}${
anchor === "start"
? ``
: anchor === "end"
? L
? (i) => ` translate(0,${L[i]})`
: ` translate(0,${length})`
: L
? (i) => ` translate(0,${L[i] / 2})`
: ` translate(0,${length / 2})`
}`
)
.attr(
"d",
L
? (i) => {
const p = path();
shape.draw(p, L[i], r);
return p;
}
: (() => {
const p = path();
shape.draw(p, length, r);
return p;
})()
)
.call(applyChannelStyles, this, channels)
)
.node();
Expand All @@ -77,19 +139,33 @@ export class Vector extends Mark {

/** @jsdoc vector */
export function vector(data, options = {}) {
let {x, y, ...remainingOptions} = options;
let {x, y, ...rest} = options;
if (options.frameAnchor === undefined) [x, y] = maybeTuple(x, y);
return new Vector(data, {...remainingOptions, x, y});
return new Vector(data, {...rest, x, y});
}

/** @jsdoc vectorX */
export function vectorX(data, options = {}) {
const {x = identity, ...remainingOptions} = options;
return new Vector(data, {...remainingOptions, x});
const {x = identity, ...rest} = options;
return new Vector(data, {...rest, x});
}

/** @jsdoc vectorY */
export function vectorY(data, options = {}) {
const {y = identity, ...remainingOptions} = options;
return new Vector(data, {...remainingOptions, y});
const {y = identity, ...rest} = options;
return new Vector(data, {...rest, y});
}

/** @jsdoc spike */
export function spike(data, options = {}) {
const {
shape = shapeSpike,
stroke = defaults.stroke,
strokeWidth = 1,
fill = stroke,
fillOpacity = 0.3,
anchor = "start",
...rest
} = options;
return vector(data, {...rest, shape, stroke, strokeWidth, fill, fillOpacity, anchor});
}
Loading

0 comments on commit b31b8cc

Please sign in to comment.