Skip to content

Commit ee3c21e

Browse files
authored
Merge branch 'main' into fil/daspect
2 parents 57f87cb + de51adf commit ee3c21e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+9788
-3121
lines changed

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# Observable Plot - Changelog
22

3+
## 0.5.0
4+
5+
*Not yet released. These are forthcoming changes in the main branch.*
6+
7+
Plot now supports mark initializers via the **initializer** option. Initializers can transform data, channels, and indexes. Unlike data transforms which operate in abstract data space, initializers can operate in screen space such as pixel coordinates and colors. For example, initializers can modify a marks’ positions to avoid occlusion. The new hexbin and dodge transforms are implemented as mark initializers.
8+
9+
The new hexbin transform functions similarly to the bin transform, except it aggregates both *x* and *y* into hexagonal bins before reducing. The size of the hexagons can be specified with the **binWidth** option, which controls the width of the (pointy-topped) hexagons.
10+
11+
The new hexgrid decoration mark draws a hexagonal grid. It is intended to be used with the hexbin transform as an alternative to the default horizontal and vertical axis grid.
12+
13+
The dot mark now supports the *hexagon* symbol type for pointy-topped hexagons. The new circle and hexagon marks are convenience shorthand for dot marks with the *circle* and *hexagon* symbol, respectively. The dotX, dotY, textX, and textY marks now support the **interval** option.
14+
15+
The new dodge transform can be used to produce beeswarm plots. Given an *x* channel representing the desired horizontal position of circles, the dodgeY transform derives a new *y* (vertical position) channel such that the circles do not overlap; the dodgeX transform similarly derives a new *x* channel given a *y* channel. If an *r* channel is specified, the circles may have varying radius.
16+
17+
The mark **sort** option now supports index sorting. For example, to sort dots by ascending radius:
18+
19+
~~~js
20+
Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: {channel: "r"}})
21+
~~~
22+
23+
The dot mark now sorts by descending radius by default to reduce occlusion.
24+
25+
The **zero** scale option (like the **nice** and **clamp** options) may now be specified as a top-level option, applying to all quantitative scales.
26+
27+
The rule mark now correctly respects the **dx** and **dy** options.
28+
29+
Fix crash when using area mark shorthand.
30+
31+
Marks can now define a channel hint to set the default range of the *r* scale. This is used by the hexbin transform when producing an *r* output channel.
32+
33+
Improve performance of internal array operations, including type coercion.
34+
35+
[breaking] Color scales with diverging color schemes now default to the *diverging* scale type instead of the *linear* scale type.
36+
37+
[breaking] *mark*.initialize return signature.
38+
339
## 0.4.3
440

541
[Released April 12, 2022.](https://github.com/observablehq/plot/releases/tag/v0.4.3)

README.md

Lines changed: 136 additions & 17 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,23 @@
3535
},
3636
"sideEffects": false,
3737
"devDependencies": {
38+
"@rollup/plugin-commonjs": "22",
3839
"@rollup/plugin-json": "4",
3940
"@rollup/plugin-node-resolve": "13",
4041
"canvas": "2",
4142
"eslint": "8",
4243
"htl": "0.3",
4344
"js-beautify": "1",
4445
"jsdom": "19",
45-
"mocha": "9",
46+
"mocha": "10",
4647
"module-alias": "2",
4748
"rollup": "2",
4849
"rollup-plugin-terser": "7",
4950
"vite": "2"
5051
},
5152
"dependencies": {
5253
"d3": "^7.3.0",
54+
"interval-tree-1d": "1",
5355
"isoformat": "0.2"
5456
},
5557
"engines": {

rollup.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs";
22
import {terser} from "rollup-plugin-terser";
3+
import commonjs from "@rollup/plugin-commonjs";
34
import json from "@rollup/plugin-json";
45
import node from "@rollup/plugin-node-resolve";
56
import * as meta from "./package.json";
@@ -25,6 +26,7 @@ const config = {
2526
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
2627
},
2728
plugins: [
29+
commonjs(),
2830
json(),
2931
node()
3032
]

src/channel.js

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {ascending, descending, rollup, sort} from "d3";
2+
import {ascendingDefined, descendingDefined} from "./defined.js";
23
import {first, labelof, map, maybeValue, range, valueof} from "./options.js";
34
import {registry} from "./scales/index.js";
45
import {maybeReduce} from "./transforms/group.js";
6+
import {composeInitializer} from "./transforms/initializer.js";
57

68
// TODO Type coercion?
79
export function Channel(data, {scale, type, value, filter, hint}) {
@@ -15,19 +17,41 @@ export function Channel(data, {scale, type, value, filter, hint}) {
1517
};
1618
}
1719

18-
export function channelSort(channels, facetChannels, data, options) {
20+
export function channelObject(channelDescriptors, data) {
21+
const channels = {};
22+
for (const channel of channelDescriptors) {
23+
channels[channel.name] = Channel(data, channel);
24+
}
25+
return channels;
26+
}
27+
28+
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
29+
export function valueObject(channels, scales) {
30+
const values = {};
31+
for (const channelName in channels) {
32+
const {scale: scaleName, value} = channels[channelName];
33+
const scale = scales[scaleName];
34+
values[channelName] = scale === undefined ? value : map(value, scale);
35+
}
36+
return values;
37+
}
38+
39+
// Note: mutates channel.domain! This is set to a function so that it is lazily
40+
// computed; i.e., if the scale’s domain is set explicitly, that takes priority
41+
// over the sort option, and we don’t need to do additional work.
42+
export function channelDomain(channels, facetChannels, data, options) {
1943
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
2044
for (const x in options) {
21-
if (!registry.has(x)) continue; // ignore unknown scale keys
45+
if (!registry.has(x)) continue; // ignore unknown scale keys (including generic options)
2246
let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
2347
if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths
2448
if (reduce == null || reduce === false) continue; // disabled reducer
25-
const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x);
49+
const X = findScaleChannel(channels, x) || facetChannels && findScaleChannel(facetChannels, x);
2650
if (!X) throw new Error(`missing channel for scale: ${x}`);
27-
const XV = X[1].value;
51+
const XV = X.value;
2852
const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit];
2953
if (y == null) {
30-
X[1].domain = () => {
54+
X.domain = () => {
3155
let domain = XV;
3256
if (reverse) domain = domain.slice().reverse();
3357
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
@@ -39,7 +63,7 @@ export function channelSort(channels, facetChannels, data, options) {
3963
: y === "width" ? difference(channels, "x1", "x2")
4064
: values(channels, y, y === "y" ? "y2" : y === "x" ? "x2" : undefined);
4165
const reducer = maybeReduce(reduce === true ? "max" : reduce, YV);
42-
X[1].domain = () => {
66+
X.domain = () => {
4367
let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]);
4468
domain = sort(domain, reverse ? descendingGroup : ascendingGroup);
4569
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
@@ -49,16 +73,39 @@ export function channelSort(channels, facetChannels, data, options) {
4973
}
5074
}
5175

76+
function sortInitializer(name, optional, compare = ascendingDefined) {
77+
return (data, facets, {[name]: V}) => {
78+
if (!V) {
79+
if (optional) return {}; // do nothing if given channel does not exist
80+
throw new Error(`missing channel: ${name}`);
81+
}
82+
V = V.value;
83+
const compareValue = (i, j) => compare(V[i], V[j]);
84+
return {facets: facets.map(I => I.slice().sort(compareValue))};
85+
};
86+
}
87+
88+
export function channelSort(initializer, {channel, optional, reverse}) {
89+
return composeInitializer(initializer, sortInitializer(channel, optional, reverse ? descendingDefined : ascendingDefined));
90+
}
91+
92+
function findScaleChannel(channels, scale) {
93+
for (const name in channels) {
94+
const channel = channels[name];
95+
if (channel.scale === scale) return channel;
96+
}
97+
}
98+
5299
function difference(channels, k1, k2) {
53100
const X1 = values(channels, k1);
54101
const X2 = values(channels, k2);
55102
return map(X2, (x2, i) => Math.abs(x2 - X1[i]), Float64Array);
56103
}
57104

58105
function values(channels, name, alias) {
59-
let channel = channels.find(([n]) => n === name);
60-
if (!channel && alias !== undefined) channel = channels.find(([n]) => n === alias);
61-
if (channel) return channel[1].value;
106+
let channel = channels[name];
107+
if (!channel && alias !== undefined) channel = channels[alias];
108+
if (channel) return channel.value;
62109
throw new Error(`missing channel: ${name}`);
63110
}
64111

src/defined.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,3 @@ export function positive(x) {
2727
export function negative(x) {
2828
return x < 0 && isFinite(x) ? x : NaN;
2929
}
30-
31-
export function firstof(...values) {
32-
for (const v of values) {
33-
if (v !== undefined) {
34-
return v;
35-
}
36-
}
37-
}

src/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ export {Arrow, arrow} from "./marks/arrow.js";
44
export {BarX, BarY, barX, barY} from "./marks/bar.js";
55
export {boxX, boxY} from "./marks/box.js";
66
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
7-
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
7+
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
88
export {Frame, frame} from "./marks/frame.js";
9+
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
910
export {Image, image} from "./marks/image.js";
1011
export {Line, line, lineX, lineY} from "./marks/line.js";
1112
export {Link, link} from "./marks/link.js";
@@ -18,7 +19,10 @@ export {Vector, vector, vectorX, vectorY} from "./marks/vector.js";
1819
export {valueof, column} from "./options.js";
1920
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
2021
export {bin, binX, binY} from "./transforms/bin.js";
22+
export {dodgeX, dodgeY} from "./transforms/dodge.js";
2123
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
24+
export {hexbin} from "./transforms/hexbin.js";
25+
export {initializer} from "./transforms/initializer.js";
2226
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
2327
export {map, mapX, mapY} from "./transforms/map.js";
2428
export {window, windowX, windowY} from "./transforms/window.js";

src/legends/ramp.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {create, quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
22
import {inferFontVariant} from "../axes.js";
3+
import {map} from "../options.js";
34
import {interpolatePiecewise} from "../scales/quantitative.js";
45
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";
56

@@ -115,7 +116,7 @@ export function legendRamp(color, {
115116
.attr("height", height - marginTop - marginBottom)
116117
.attr("fill", d => d);
117118

118-
ticks = Array.from(thresholds, (_, i) => i);
119+
ticks = map(thresholds, (_, i) => i);
119120
tickFormat = i => thresholdFormat(thresholds[i], i);
120121
}
121122

src/marks/dot.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {create, path, symbolCircle} from "d3";
22
import {positive} from "../defined.js";
3-
import {identity, maybeFrameAnchor, maybeNumberChannel, maybeSymbolChannel, maybeTuple} from "../options.js";
3+
import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../options.js";
44
import {Mark} from "../plot.js";
55
import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, offset} from "../style.js";
6+
import {maybeSymbolChannel} from "../symbols.js";
7+
import {maybeIntervalMidX, maybeIntervalMidY} from "../transforms/interval.js";
68

79
const defaults = {
810
ariaLabel: "dot",
@@ -26,7 +28,7 @@ export class Dot extends Mark {
2628
{name: "rotate", value: vrotate, optional: true},
2729
{name: "symbol", value: vsymbol, scale: "symbol", optional: true}
2830
],
29-
options,
31+
options.sort === undefined ? {...options, sort: {channel: "r", optional: true, reverse: true}} : options,
3032
defaults
3133
);
3234
this.r = cr;
@@ -94,9 +96,17 @@ export function dot(data, {x, y, ...options} = {}) {
9496
}
9597

9698
export function dotX(data, {x = identity, ...options} = {}) {
97-
return new Dot(data, {...options, x});
99+
return new Dot(data, maybeIntervalMidY({...options, x}));
98100
}
99101

100102
export function dotY(data, {y = identity, ...options} = {}) {
101-
return new Dot(data, {...options, y});
103+
return new Dot(data, maybeIntervalMidX({...options, y}));
104+
}
105+
106+
export function circle(data, options) {
107+
return dot(data, {...options, symbol: "circle"});
108+
}
109+
110+
export function hexagon(data, options) {
111+
return dot(data, {...options, symbol: "hexagon"});
102112
}

src/marks/hexgrid.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {create} from "d3";
2+
import {Mark} from "../plot.js";
3+
import {number} from "../options.js";
4+
import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
5+
import {sqrt4_3} from "../symbols.js";
6+
import {ox, oy} from "../transforms/hexbin.js";
7+
8+
const defaults = {
9+
ariaLabel: "hexgrid",
10+
fill: "none",
11+
stroke: "currentColor",
12+
strokeOpacity: 0.1
13+
};
14+
15+
export function hexgrid(options) {
16+
return new Hexgrid(options);
17+
}
18+
19+
export class Hexgrid extends Mark {
20+
constructor({binWidth = 20, clip = true, ...options} = {}) {
21+
super(undefined, undefined, {clip, ...options}, defaults);
22+
this.binWidth = number(binWidth);
23+
}
24+
render(index, scales, channels, dimensions) {
25+
const {dx, dy, binWidth} = this;
26+
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
27+
const x0 = marginLeft - ox, x1 = width - marginRight - ox, y0 = marginTop - oy, y1 = height - marginBottom - oy;
28+
const rx = binWidth / 2, ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5;
29+
const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`;
30+
const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx);
31+
const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1;
32+
const m = [];
33+
for (let j = j0; j < j1; ++j) {
34+
for (let i = i0; i < i1; ++i) {
35+
m.push(`M${i * wx + (j & 1) * rx},${j * wy}${path}`);
36+
}
37+
}
38+
return create("svg:g")
39+
.call(applyIndirectStyles, this, dimensions)
40+
.call(g => g.append("path")
41+
.call(applyDirectStyles, this)
42+
.call(applyTransform, null, null, offset + dx + ox, offset + dy + oy)
43+
.attr("d", m.join("")))
44+
.node();
45+
}
46+
}

0 commit comments

Comments
 (0)