Skip to content

spike vector shape + centroid transforms #1189

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

Merged
merged 16 commits into from
Dec 20, 2022
Merged
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