Skip to content

Commit d17b34f

Browse files
mbostockFil
andauthored
difference mark and shift transform (#1896)
* difference mark * fix filtering; opacity options * remove unused import * withTip; don’t duplicate channels * difference as a composite mark * difference tip * reuse channels * more composite marks * apply clip as render transform * consolidate code * aria labels * organize imports * fix differenceY1 test * update tests * better defaults * handle ChannelValueSpec * update test * memoTuple * checkpoint docs * fix differenceY1 test * tip fixes * **positiveOpacity**, **negativeOpacity** default to **fillOpacity**; **positive**, **negative** are the fill colors for their respective difference areas * positiveFill * another test * positiveFillOpacity & fix test * swap [xy][12]; default y1 = 0 * shift option * another difference example * z * simpler marks (no need for two differences) * inferScaleOrder * simpler chart * enhanced group extent; findX sketch * shift transform * shift domain hint * promote stroke to z * simpler channel domain hint * more difference docs * more difference docs * more documentation * call next twice (once for the path, once for the clipPath) * support clip: frame * document differenceY * test ordinal difference * adopt Plot.find * more docs --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent 318c0eb commit d17b34f

Some content is hidden

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

41 files changed

+12209
-22
lines changed

docs/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export default defineConfig({
8888
{text: "Contour", link: "/marks/contour"},
8989
{text: "Delaunay", link: "/marks/delaunay"},
9090
{text: "Density", link: "/marks/density"},
91+
{text: "Difference", link: "/marks/difference"},
9192
{text: "Dot", link: "/marks/dot"},
9293
{text: "Frame", link: "/marks/frame"},
9394
{text: "Geo", link: "/marks/geo"},
@@ -121,6 +122,7 @@ export default defineConfig({
121122
{text: "Map", link: "/transforms/map"},
122123
{text: "Normalize", link: "/transforms/normalize"},
123124
{text: "Select", link: "/transforms/select"},
125+
{text: "Shift", link: "/transforms/shift"},
124126
{text: "Sort", link: "/transforms/sort"},
125127
{text: "Stack", link: "/transforms/stack"},
126128
{text: "Tree", link: "/transforms/tree"},

docs/data/tsa.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../test/data/tsa.csv

docs/marks/difference.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<script setup>
2+
3+
import * as Plot from "@observablehq/plot";
4+
import * as d3 from "d3";
5+
import {computed, shallowRef, onMounted} from "vue";
6+
7+
const aapl = shallowRef([]);
8+
const gistemp = shallowRef([]);
9+
const tsa = shallowRef([{Date: new Date("2020-01-01")}]);
10+
const temperature = shallowRef([{date: new Date("2020-01-01")}]);
11+
12+
onMounted(() => {
13+
d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data));
14+
d3.csv("../data/gistemp.csv", d3.autoType).then((data) => (gistemp.value = data));
15+
d3.csv("../data/tsa.csv",d3.autoType).then((data) => (tsa.value = data));
16+
d3.csv("../data/sf-sj-temperatures.csv", d3.autoType).then((data) => (temperature.value = data.filter((d) => d.date.getUTCFullYear() === 2020)));
17+
});
18+
19+
</script>
20+
21+
# Difference mark <VersionBadge pr="1896" />
22+
23+
The **difference mark** puts a metric in context by comparing it. Like the [area mark](./area.md), the region between two lines is filled; unlike the area mark, alternating color shows when the metric is above or below the comparison value.
24+
25+
In the simplest case, the difference mark compares a metric to a constant. For example, the plot below shows the [global surface temperature anomaly](https://data.giss.nasa.gov/gistemp/) from 1880–2016; 0° represents the 1951–1980 average; above-average temperatures are in <span style="border-bottom: solid var(--vp-c-red) 3px;">red</span>, while below-average temperatures are in <span style="border-bottom: solid var(--vp-c-blue) 3px;">blue</span>. (It’s getting hotter.)
26+
27+
:::plot
28+
```js
29+
Plot.differenceY(gistemp, {
30+
x: "Date",
31+
y: "Anomaly",
32+
positiveFill: "red",
33+
negativeFill: "blue",
34+
tip: true
35+
}).plot({y: {grid: true}})
36+
```
37+
:::
38+
39+
A 24-month [moving average](../transforms/window.md) improves readability by smoothing out the noise.
40+
41+
:::plot
42+
```js
43+
Plot.differenceY(
44+
gistemp,
45+
Plot.windowY(12 * 2, {
46+
x: "Date",
47+
y: "Anomaly",
48+
positiveFill: "red",
49+
negativeFill: "blue",
50+
tip: true
51+
})
52+
).plot({y: {grid: true}})
53+
```
54+
:::
55+
56+
More powerfully, the difference mark compares two metrics. For example, the plot below shows the number of travelers per day through TSA checkpoints in 2020 compared to 2019. (This in effect compares a metric against itself, but as the data represents each year as a separate column, it is equivalent to two metrics.) In the first two months of 2020, there were on average <span style="border-bottom: solid #01ab63 3px;">more travelers</span> per day than 2019; yet when COVID-19 hit, there were many <span style="border-bottom: solid #4269d0 3px;">fewer travelers</span> per day, dropping almost to zero.
57+
58+
:::plot
59+
```js
60+
Plot.plot({
61+
x: {tickFormat: "%b"},
62+
y: {grid: true, label: "Travelers"},
63+
marks: [
64+
Plot.axisY({label: "Travelers per day (thousands, 2020 vs. 2019)", tickFormat: (d) => d / 1000}),
65+
Plot.ruleY([0]),
66+
Plot.differenceY(tsa, {x: "Date", y1: "2019", y2: "2020", tip: {format: {x: "%B %-d"}}})
67+
]
68+
})
69+
```
70+
:::
71+
72+
If the data is “tall” rather than “wide” — that is, if the two metrics we wish to compare are represented by separate *rows* rather than separate *columns* — we can use the [group transform](../transforms/group.md) with the [find reducer](../transforms/group.md#find): group the rows by **x** (date), then find the desired **y1** and **y2** for each group. The plot below shows daily minimum temperature for San Francisco compared to San Jose. Notice how the insulating fog keeps San Francisco <span style="border-bottom: solid #01ab63 3px;">warmer</span> in winter and <span style="border-bottom: solid #4269d0 3px;">cooler</span> in summer, reducing seasonal variation.
73+
74+
:::plot
75+
```js
76+
Plot.plot({
77+
x: {tickFormat: "%b"},
78+
y: {grid: true},
79+
marks: [
80+
Plot.ruleY([32]),
81+
Plot.differenceY(
82+
temperature,
83+
Plot.windowY(
84+
14,
85+
Plot.groupX(
86+
{
87+
y1: Plot.find((d) => d.station === "SJ"),
88+
y2: Plot.find((d) => d.station === "SF")
89+
},
90+
{
91+
x: "date",
92+
y: "tmin",
93+
tip: true
94+
}
95+
)
96+
)
97+
)
98+
]
99+
})
100+
```
101+
:::
102+
103+
The difference mark can also be used to compare a metric to itself using the [shift transform](../transforms/shift.md). The chart below shows year-over-year growth in the price of Apple stock.
104+
105+
:::plot
106+
```js
107+
Plot.differenceY(aapl, Plot.shiftX("+1 year", {x: "Date", y: "Close"})).plot({y: {grid: true}})
108+
```
109+
:::
110+
111+
For most of the covered time period, you would have <span style="border-bottom: solid #01ab63 3px;">made a profit</span> by holding Apple stock for a year; however, if you bought in 2015 and sold in 2016, you would likely have <span style="border-bottom: solid #4269d0 3px;">lost money</span>.
112+
113+
## Difference options
114+
115+
The following channels are required:
116+
117+
* **x2** - the horizontal position of the metric; bound to the *x* scale
118+
* **y2** - the vertical position of the metric; bound to the *y* scale
119+
120+
In addition to the [standard mark options](../features/marks.md#mark-options), the following optional channels are supported:
121+
122+
* **x1** - the horizontal position of the comparison; bound to the *x* scale
123+
* **y1** - the vertical position of the comparison; bound to the *y* scale
124+
125+
If **x1** is not specified, it defaults to **x2**. If **y1** is not specified, it defaults to 0 if **x1** and **x2** are equal, and to **y2** otherwise. These defaults facilitate sharing *x* or *y* coordinates between the metric and its comparison.
126+
127+
The standard **fill** option is ignored; instead, there are separate channels based on the sign of the difference:
128+
129+
* **positiveFill** - the color for when the metric is greater, defaults to <span style="border-bottom:solid #01ab63 3px;">green</span>
130+
* **negativeFill** - the color for when the comparison is greater, defaults to <span style="border-bottom:solid #4269d0 3px;">blue</span>
131+
* **fillOpacity** - the areas’ opacity, defaults to 1
132+
* **positiveFillOpacity** - the positive area’s opacity, defaults to *opacity*
133+
* **negativeFillOpacity** - the negative area’s opacity, defaults to *opacity*
134+
* **stroke** - the metric line’s stroke color, defaults to currentColor
135+
* **strokeOpacity** - the metric line’s opacity, defaults to 1
136+
137+
These options are passed to the underlying area and line marks; in particular, when they are defined as a channel, the underlying marks are broken into contiguous overlapping segments when the values change. When any of these channels are used, setting an explicit **z** channel (possibly to null) is strongly recommended.
138+
139+
## differenceY(*data*, *options*) {#differenceY}
140+
141+
```js
142+
Plot.differenceY(gistemp, {x: "Date", y: "Anomaly"})
143+
```
144+
145+
Returns a new difference with the given *data* and *options*. The mark is a composite of a positive area, negative area, and line. The positive area extends from the bottom of the frame to the line, and is clipped by the area extending from the comparison to the top of the frame. The negative area conversely extends from the top of the frame to the line, and is clipped by the area extending from the comparison to the bottom of the frame.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../test/data/sf-sj-temperatures.csv

docs/transforms/shift.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup>
2+
3+
import * as Plot from "@observablehq/plot";
4+
import * as d3 from "d3";
5+
import {ref, shallowRef, onMounted} from "vue";
6+
7+
const shift = ref(365);
8+
const aapl = shallowRef([]);
9+
10+
onMounted(() => {
11+
d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data));
12+
});
13+
14+
</script>
15+
16+
# Shift transform <VersionBadge pr="1896" />
17+
18+
The **shift transform** is a specialized [map transform](./map.md) that derives an output **x1** channel by shifting the **x** channel; it can be used with the [difference mark](../marks/difference.md) to show change over time. For example, the chart below shows the price of Apple stock. The <span style="border-bottom: solid #01ab63 3px;">green region</span> shows when the price went up over the given interval, while the <span style="border-bottom: solid #4269d0 3px;">blue region</span> shows when the price went down.
19+
20+
<p>
21+
<label class="label-input" style="display: flex;">
22+
<span style="display: inline-block; width: 7em;">Shift (days):</span>
23+
<input type="range" v-model.number="shift" min="0" max="1000" step="1">
24+
<span style="font-variant-numeric: tabular-nums;">{{shift}}</span>
25+
</label>
26+
</p>
27+
28+
:::plot hidden
29+
```js
30+
Plot.differenceY(aapl, Plot.shiftX(`${shift} days`, {x: "Date", y: "Close"})).plot({y: {grid: true}})
31+
```
32+
:::
33+
34+
```js-vue
35+
Plot.differenceY(aapl, Plot.shiftX("{{shift}} days", {x: "Date", y: "Close"})).plot({y: {grid: true}})
36+
```
37+
38+
When looking at year-over-year growth, the chart is mostly green, implying that you would make a profit by holding Apple stock for a year. However, if you bought in 2015 and sold in 2016, you would likely have lost money. Try adjusting the slider to a shorter or longer interval: how does that affect the typical return?
39+
40+
## shiftX(*interval*, *options*) {#shiftX}
41+
42+
```js
43+
Plot.shiftX("7 days", {x: "Date", y: "Close"})
44+
```
45+
46+
Derives an **x1** channel from the input **x** channel by shifting values by the given *interval*. The *interval* may be specified as: a name (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) with an optional number and sign (*e.g.*, *+3 days* or *-1 year*); or as a number; or as an implementation — such as d3.utcMonth — with *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods.
47+
48+
The shiftX also transform aliases the **x** channel to **x2** and applies a domain hint to the **x2** channel such that by default the plot shows only the intersection of **x1** and **x2**. For example, if the interval is *+1 year*, the first year of the data is not shown.

src/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export * from "./marks/contour.js";
2121
export * from "./marks/crosshair.js";
2222
export * from "./marks/delaunay.js";
2323
export * from "./marks/density.js";
24+
export * from "./marks/difference.js";
2425
export * from "./marks/dot.js";
2526
export * from "./marks/frame.js";
2627
export * from "./marks/geo.js";
@@ -52,6 +53,7 @@ export * from "./transforms/hexbin.js";
5253
export * from "./transforms/map.js";
5354
export * from "./transforms/normalize.js";
5455
export * from "./transforms/select.js";
56+
export * from "./transforms/shift.js";
5557
export * from "./transforms/stack.js";
5658
export * from "./transforms/tree.js";
5759
export * from "./transforms/window.js";

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {Contour, contour} from "./marks/contour.js";
1212
export {crosshair, crosshairX, crosshairY} from "./marks/crosshair.js";
1313
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
1414
export {Density, density} from "./marks/density.js";
15+
export {differenceY} from "./marks/difference.js";
1516
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
1617
export {Frame, frame} from "./marks/frame.js";
1718
export {Geo, geo, sphere, graticule} from "./marks/geo.js";
@@ -38,6 +39,7 @@ export {find, group, groupX, groupY, groupZ} from "./transforms/group.js";
3839
export {hexbin} from "./transforms/hexbin.js";
3940
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
4041
export {map, mapX, mapY} from "./transforms/map.js";
42+
export {shiftX} from "./transforms/shift.js";
4143
export {window, windowX, windowY} from "./transforms/window.js";
4244
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
4345
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";

src/marks/axis.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {formatDefault} from "../format.js";
33
import {marks} from "../mark.js";
44
import {radians} from "../math.js";
55
import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js";
6-
import {isIterable, isNoneish, isTemporal, isInterval, orderof} from "../options.js";
6+
import {isIterable, isNoneish, isTemporal, isInterval} from "../options.js";
77
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
8+
import {inferScaleOrder} from "../scales.js";
89
import {offset} from "../style.js";
910
import {generalizeTimeInterval, inferTimeFormat, intervalDuration} from "../time.js";
1011
import {initializer} from "../transforms/basic.js";
@@ -686,13 +687,6 @@ function inferFontVariant(scale) {
686687
return scale.bandwidth && !scale.interval ? undefined : "tabular-nums";
687688
}
688689

689-
// Determines whether the scale points in the “positive” (right or down) or
690-
// “negative” (left or up) direction; if the scale order cannot be determined,
691-
// returns NaN; used to assign an appropriate label arrow.
692-
function inferScaleOrder(scale) {
693-
return Math.sign(orderof(scale.domain())) * Math.sign(orderof(scale.range()));
694-
}
695-
696690
// Takes the scale label, and if this is not an ordinal scale and the label was
697691
// inferred from an associated channel, adds an orientation-appropriate arrow.
698692
function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {

src/marks/difference.d.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type {ChannelValue, ChannelValueSpec} from "../channel.js";
2+
import type {CurveOptions} from "../curve.js";
3+
import type {Data, MarkOptions, RenderableMark} from "../mark.js";
4+
5+
/** Options for the difference mark. */
6+
export interface DifferenceOptions extends MarkOptions, CurveOptions {
7+
/**
8+
* The comparison horizontal position channel, typically bound to the *x*
9+
* scale; if not specified, **x** is used.
10+
*/
11+
x1?: ChannelValueSpec;
12+
13+
/**
14+
* The primary horizontal position channel, typically bound to the *x* scale;
15+
* if not specified, **x1** is used.
16+
*/
17+
x2?: ChannelValueSpec;
18+
19+
/** The horizontal position channel, typically bound to the *x* scale. */
20+
x?: ChannelValueSpec;
21+
22+
/**
23+
* The comparison vertical position channel, typically bound to the *y* scale;
24+
* if not specified, **y** is used. For differenceY, defaults to zero if only
25+
* one *x* and *y* channel is specified.
26+
*/
27+
y1?: ChannelValueSpec;
28+
29+
/**
30+
* The primary vertical position channel, typically bound to the *y* scale;
31+
* if not specified, **y1** is used.
32+
*/
33+
y2?: ChannelValueSpec;
34+
35+
/** The vertical position channel, typically bound to the *y* scale. */
36+
y?: ChannelValueSpec;
37+
38+
/**
39+
* The fill color when the primary value is greater than the secondary value;
40+
* defaults to green.
41+
*/
42+
positiveFill?: ChannelValueSpec;
43+
44+
/**
45+
* The fill color when the primary value is less than the secondary value;
46+
* defaults to blue.
47+
*/
48+
negativeFill?: ChannelValueSpec;
49+
50+
/** The fill opacity; defaults to 1. */
51+
fillOpacity?: number;
52+
53+
/**
54+
* The fill opacity when the primary value is greater than the secondary
55+
* value; defaults to **fillOpacity**.
56+
*/
57+
positiveFillOpacity?: number;
58+
59+
/**
60+
* The fill opacity when the primary value is less than the secondary value;
61+
* defaults to **fillOpacity**.
62+
*/
63+
negativeFillOpacity?: number;
64+
65+
/**
66+
* An optional ordinal channel for grouping data into series to be drawn as
67+
* separate areas; defaults to **stroke**, if a channel.
68+
*/
69+
z?: ChannelValue;
70+
}
71+
72+
/**
73+
* Returns a new vertical difference mark for the given the specified *data* and
74+
* *options*, as in a time-series chart where time goes right→ (or ←left).
75+
*
76+
* The mark is a composite of a positive area, negative area, and line. The
77+
* positive area extends from the bottom of the frame to the line, and is
78+
* clipped by the area extending from the comparison to the top of the frame.
79+
* The negative area conversely extends from the top of the frame to the line,
80+
* and is clipped by the area extending from the comparison to the bottom of the
81+
* frame.
82+
*/
83+
export function differenceY(data?: Data, options?: DifferenceOptions): Difference;
84+
85+
/** The difference mark. */
86+
export class Difference extends RenderableMark {}

0 commit comments

Comments
 (0)