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

document layouts (as "scale-aware transforms") #819

Closed
wants to merge 12 commits into from
75 changes: 73 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,14 @@ Plot.dotY(cars.map(d => d["economy (mpg)"]))

Equivalent to [Plot.dot](#plotdotdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …].

### Hexgrid

The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout.

#### Plot.hexgrid([*options*])

The *binWidth* option specifies the distance between the centers of neighboring hexagons, in pixels (defaults to 20). The *clip* option defaults to true, clipping the mark to the frame’s dimensions.

### Image

[<img src="./img/image.png" width="320" height="198" alt="a scatterplot of Presidential portraits">](https://observablehq.com/@observablehq/plot-image)
Expand Down Expand Up @@ -1437,10 +1445,10 @@ The following aggregation methods are supported:
* *pXX* - the percentile value, where XX is a number in [00,99]
* *deviation* - the standard deviation
* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm)
* *x* - the middle the bin’s *x*-extent (when binning on *x*)
* *x* - the middle of the bin’s *x*-extent (when binning on *x*)
* *x1* - the lower bound of the bin’s *x*-extent (when binning on *x*)
* *x2* - the upper bound of the bin’s *x*-extent (when binning on *x*)
* *y* - the middle the bin’s *y*-extent (when binning on *y*)
* *y* - the middle of the bin’s *y*-extent (when binning on *y*)
* *y1* - the lower bound of the bin’s *y*-extent (when binning on *y*)
* *y2* - the upper bound of the bin’s *y*-extent (when binning on *y*)
* a function to be passed the array of values for each bin and the extent of the bin
Expand Down Expand Up @@ -1939,6 +1947,69 @@ This helper for constructing derived channels returns a [*channel*, *setChannel*

Plot.channel is typically used by options transforms to define new channels; these channels are populated (derived) when the custom *transform* function is invoked.

## Scale-aware transforms

Some transforms need to operate in representation space (such as pixels and colors, *i.e.* after scales have been applied) rather than data space. Such a transform might, for example, modify the marks’ positions in screen space to avoid occlusion. These scale-aware transforms are applied *after* the initial setting of the scales, and can modify the channels or derive new channels—which can in turn be passed to scales.

### Dodge

The dodge transform can be applied to any mark that consumes *x* or *y*, such as the Dot, Image, Text and Vector marks.
#### Plot.dodgeY([*layoutOptions*, ]*options*)

```js
Plot.dodgeY({x: "date"})
```

If the marks are arranged along the *x* axis, the dodgeY transform piles them vertically, keeping their *x* position unchanged, and creating a *y* position that avoids overlapping.
Copy link

Choose a reason for hiding this comment

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

@Fil hi, well, i really wants this functionary. which topic branch is the most updated one (ideally based on plot v0.4.3)?

Copy link

@ryoqun ryoqun May 4, 2022

Choose a reason for hiding this comment

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

specifically, i want these Plot.links to be stacked (currently overlapping):
image

work-arounds like opacity and dashed lines aren't satisfactory... xD

and it seems i can specify the stacking direction with anchor with top & bottom? perfect. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ryoqun the current branch is https://github.com/observablehq/plot/tree/mbostock/reinitialize; please feel free to test it and give us feedback. (But note that it's highly probable that more changes will occur before this ships.)


#### Plot.dodgeX([*layoutOptions*, ]*options*)

```js
Plot.dodgeX({y: "value"})
```

Equivalent to Plot.dodgeY, but the piling is horizontal, keeping the marks’ *y* position unchanged, and creating an *x* position that avoids overlapping.
The dodge transforms accept the following options:
* **padding** — a number of pixels added to the radius of the mark to estimate its size
* **anchor** - the frame anchor: one of *middle*, *right*, and *left* (default) for dodgeX, and one of *middle*, *top*, and *bottom* (default) for dodgeY. With the *middle* anchor the piles will grow from the center in both directions; with the other anchors, the piles will grow from the specified anchor towards the opposite direction.

### Hexbin

The hexbin transform can be applied to any mark that consumes *x* and *y*, such as the Dot, Image, Text and Vector marks. It aggregates the values into hexagonal bins of the given *radius* (in pixel space), and computes new values *x* and *y* as the centers of each bin. It can also return new channels by applying a reducer to each bin, such as the number of elements in the bin.

#### Plot.hexbin(*outputs*, *options*)

[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Aggregates the given inputs into hexagonal bins, and creates output channels with the reduced data. The options must specify the *x* and *y* channels, and can optionally indicate the *binWidth* in pixels (defaults to 20), defined as the distance between the centers of two neighboring hexagons. If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. The *outputs* options are similar to Plot.bin’s outputs; each output channel receives as input, for each hexagon, the subset of the data which has been matched to its center. The outputs object specifies the aggregation method for each output channel.

The following aggregation methods are supported:

* *first* - the first value, in input order
* *last* - the last value, in input order
* *count* - the number of elements (frequency)
* *distinct* - the number of distinct values
* *sum* - the sum of values
* *proportion* - the sum proportional to the overall total (weighted frequency)
* *proportion-facet* - the sum proportional to the facet total
* *min* - the minimum value
* *min-index* - the zero-based index of the minimum value
* *max* - the maximum value
* *max-index* - the zero-based index of the maximum value
* *mean* - the mean value (average)
* *median* - the median value
* *deviation* - the standard deviation
* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm)
* *mode* - the value with the most occurrences
* a function to be passed the array of values for each bin and the extent of the bin
* an object with a *reduce* method

When the hexbin transform has an *r* output, the bins are returned in decreasing size order.

See also the [hexgrid](#hexgrid) mark.

### Custom scale-aware transforms

When its *options* have an *initialize* property, the initialize function is called after the scales have been computed. It receives as inputs the (possibly transformed) data array, the index of elements of this array that belong to each facet, the input channels (as a key: array object), the scales, and the dimensions, with the mark as this. It must return the data, index, and the channels that need to be scaled in a second pass.

## Curves

A curve defines how to turn a discrete representation of a line as a sequence of points [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] into a continuous path; *i.e.*, how to interpolate between points. Curves are used by the [line](#line), [area](#area), and [link](#link) mark, and are implemented by [d3-shape](https://github.com/d3/d3-shape/blob/master/README.md#curves).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"dependencies": {
"d3": "^7.3.0",
"d3-hexbin": "^0.2.2",
"isoformat": "0.2"
},
"engines": {
Expand Down
43 changes: 36 additions & 7 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,41 @@ export function Channel(data, {scale, type, value, filter, hint}) {
};
}

export function channelObject(channelDescriptors, data) {
const channels = {};
for (const channel of channelDescriptors) {
channels[channel.name] = Channel(data, channel);
}
return channels;
}

// TODO Use Float64Array for scales with numeric ranges, e.g. position?
export function valueObject(channels, scales) {
const values = {};
for (const channelName in channels) {
const {scale: scaleName, value} = channels[channelName];
const scale = scales[scaleName];
values[channelName] = scale === undefined ? value : Array.from(value, scale);
}
return values;
}

// Note: mutates channel.domain! This is set to a function so that it is lazily
// computed; i.e., if the scale’s domain is set explicitly, that takes priority
// over the sort option, and we don’t need to do additional work.
export function channelSort(channels, facetChannels, data, options) {
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
for (const x in options) {
if (!registry.has(x)) continue; // ignore unknown scale keys
let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths
if (reduce == null || reduce === false) continue; // disabled reducer
const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x);
const X = findScaleChannel(channels, x) || facetChannels && findScaleChannel(facetChannels, x);
if (!X) throw new Error(`missing channel for scale: ${x}`);
const XV = X[1].value;
const XV = X.value;
const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit];
if (y == null) {
X[1].domain = () => {
X.domain = () => {
let domain = XV;
if (reverse) domain = domain.slice().reverse();
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
Expand All @@ -39,7 +61,7 @@ export function channelSort(channels, facetChannels, data, options) {
: y === "width" ? difference(channels, "x1", "x2")
: values(channels, y, y === "y" ? "y2" : y === "x" ? "x2" : undefined);
const reducer = maybeReduce(reduce === true ? "max" : reduce, YV);
X[1].domain = () => {
X.domain = () => {
let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]);
domain = sort(domain, reverse ? descendingGroup : ascendingGroup);
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
Expand All @@ -49,16 +71,23 @@ export function channelSort(channels, facetChannels, data, options) {
}
}

function findScaleChannel(channels, scale) {
for (const name in channels) {
const channel = channels[name];
if (channel.scale === scale) return channel;
}
}

function difference(channels, k1, k2) {
const X1 = values(channels, k1);
const X2 = values(channels, k2);
return Float64Array.from(X2, (x2, i) => Math.abs(x2 - X1[i]));
}

function values(channels, name, alias) {
let channel = channels.find(([n]) => n === name);
if (!channel && alias !== undefined) channel = channels.find(([n]) => n === alias);
if (channel) return channel[1].value;
let channel = channels[name];
if (!channel && alias !== undefined) channel = channels[alias];
if (channel) return channel.value;
throw new Error(`missing channel: ${name}`);
}

Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {boxX, boxY} from "./marks/box.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
Expand All @@ -18,6 +19,7 @@ export {valueof, channel} from "./options.js";
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
export {hexbin} from "./transforms/hexbin.js";
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
export {map, mapX, mapY} from "./transforms/map.js";
export {window, windowX, windowY} from "./transforms/window.js";
Expand Down
11 changes: 10 additions & 1 deletion src/marks/dot.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {create, path, symbolCircle} from "d3";
import {positive} from "../defined.js";
import {identity, maybeFrameAnchor, maybeNumberChannel, maybeSymbolChannel, maybeTuple} from "../options.js";
import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../options.js";
import {Mark} from "../plot.js";
import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, offset} from "../style.js";
import {maybeSymbolChannel} from "../symbols.js";

const defaults = {
ariaLabel: "dot",
Expand Down Expand Up @@ -100,3 +101,11 @@ export function dotX(data, {x = identity, ...options} = {}) {
export function dotY(data, {y = identity, ...options} = {}) {
return new Dot(data, {...options, y});
}

export function circle(data, options) {
return dot(data, {...options, symbol: "circle"});
}

export function hexagon(data, options) {
return dot(data, {...options, symbol: "hexagon"});
}
45 changes: 45 additions & 0 deletions src/marks/hexgrid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {create} from "d3";
import {Mark} from "../plot.js";
import {number} from "../options.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
import {sqrt4_3} from "../symbols.js";

const defaults = {
ariaLabel: "hexgrid",
fill: "none",
stroke: "currentColor",
strokeOpacity: 0.1
};

export function hexgrid(options) {
return new Hexgrid(options);
}

export class Hexgrid extends Mark {
constructor({radius = 10, clip = true, ...options} = {}) {
super(undefined, undefined, {clip, ...options}, defaults);
this.radius = number(radius);
}
render(index, scales, channels, dimensions) {
const {dx, dy, radius: rx} = this;
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
const x0 = marginLeft, x1 = width - marginRight, y0 = marginTop, y1 = height - marginBottom;
const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5;
const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`;
const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx);
const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1;
const m = [];
for (let j = j0; j < j1; ++j) {
for (let i = i0; i < i1; ++i) {
m.push(`M${i * wx + (j & 1) * rx},${j * wy}${path}`);
}
}
return create("svg:g")
.call(applyIndirectStyles, this, dimensions)
.call(g => g.append("path")
.call(applyDirectStyles, this)
.call(applyTransform, null, null, offset + dx, offset + dy)
.attr("d", m.join("")))
.node();
}
}
45 changes: 1 addition & 44 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {parse as isoParse} from "isoformat";
import {color, descending, quantile} from "d3";
import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3";
import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3";

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
const TypedArray = Object.getPrototypeOf(Uint8Array);
Expand All @@ -22,6 +20,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 yes = () => true;
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 Expand Up @@ -305,48 +304,6 @@ export function isRound(value) {
return /^\s*round\s*$/i.test(value);
}

const symbols = new Map([
["asterisk", symbolAsterisk],
["circle", symbolCircle],
["cross", symbolCross],
["diamond", symbolDiamond],
["diamond2", symbolDiamond2],
["plus", symbolPlus],
["square", symbolSquare],
["square2", symbolSquare2],
["star", symbolStar],
["times", symbolTimes],
["triangle", symbolTriangle],
["triangle2", symbolTriangle2],
["wye", symbolWye]
]);

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

export function isSymbol(value) {
if (isSymbolObject(value)) return true;
if (typeof value !== "string") return false;
return symbols.has(value.toLowerCase());
}

export function maybeSymbol(symbol) {
if (symbol == null || isSymbolObject(symbol)) return symbol;
const value = symbols.get(`${symbol}`.toLowerCase());
if (value) return value;
throw new Error(`invalid symbol: ${symbol}`);
}

export function maybeSymbolChannel(symbol) {
if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol];
if (typeof symbol === "string") {
const value = symbols.get(`${symbol}`.toLowerCase());
if (value) return [undefined, value];
}
return [symbol, undefined];
}

export function maybeFrameAnchor(value = "middle") {
return keyword(value, "frameAnchor", ["middle", "top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"]);
}
Expand Down
Loading