Skip to content

Commit bc303d5

Browse files
Filmbostock
andauthored
legends - 2 (#583)
* legends * Plot.legend takes a scale and options * Remove Plot.legend, use chart.legend instead. * reinstate Plot.legend * unused code * allow legend: "ramp" as an option * snapshot test for a lot of color legends * no random in unit tests * remove redundant color tests * accept a label on swatches * className for legendSwatches * remove radius and opacity legends for now * more scope * only color is available in this branch * do not expose any class * error on unknown legend type * categorical is normalized to ordinal * show unknown legend type in error * prEtTieR * prioritize type; avoid unnecessary default * non-nullish, not truthy * div.append(…nodes) * legends * Plot.legend takes a scale and options * Remove Plot.legend, use chart.legend instead. * reinstate Plot.legend * unused code * allow legend: "ramp" as an option * snapshot test for a lot of color legends * no random in unit tests * remove redundant color tests * accept a label on swatches * className for legendSwatches * remove radius and opacity legends for now * more scope * only color is available in this branch * do not expose any class * error on unknown legend type * categorical is normalized to ordinal * show unknown legend type in error * prEtTieR * prioritize type; avoid unnecessary default * non-nullish, not truthy * div.append(…nodes) * less duck typing * remove entity filtering * reduce duck-typing * add a test for Plot.legend with options * styles * clear up some confusion between scale options and legend options * className * apply() rather than color() * Update README * revert figure changes * avoid closure * applyInlineStyles * revert diverging scale changes * inline styles; fix diverging; separate tests * stringify and lowercase legend option * normalizeScale * inherit scale options * fix ordinal tickFormat function * explicit ordinal ticks * use pushState for tests * round option * fix for truncated schemes * opacity legend (#587) * opacity legend * add color option * legend: true * fix test determinism * fix inline opacity legends * arrow key navigation * ignore style if null Co-authored-by: Mike Bostock <[email protected]>
1 parent 18e7fbb commit bc303d5

File tree

66 files changed

+2574
-70
lines changed

Some content is hidden

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

66 files changed

+2574
-70
lines changed

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,42 @@ For convenience, an apply method is exposed, which returns the scale’s output
225225

226226
The scale object is undefined if the associated plot has no scale with the given *name*, and throws an error if the *name* is invalid (*i.e.*, not one of the known scale names: *x*, *y*, *fx*, *fy*, *r*, *color*, or *opacity*).
227227

228+
### Legends
229+
230+
Given a scale definition, Plot can generate a legend.
231+
232+
#### *chart*.legend(*name*[, *options*])
233+
234+
Returns a suitable legend for the chart’s scale with the given *name*. For now, only *color* legends are supported.
235+
236+
Categorical and ordinal color legends are rendered as swatches, unless *options*.**legend** is set to *ramp*. The swatches can be configured with the following options:
237+
238+
* *options*.**tickFormat** - a format function for the labels
239+
* *options*.**swatchSize** - the size of the swatch (if square)
240+
* *options*.**swatchWidth** - the swatches’ width
241+
* *options*.**swatchHeight** - the swatches’ height
242+
* *options*.**columns** - the number of swatches per row
243+
* *options*.**marginLeft** - the legend’s left margin
244+
* *options*.**className** - a class name, that defaults to a randomly generated string scoping the styles
245+
246+
Continuous color legends are rendered as a ramp, and can be configured with the following options:
247+
248+
* *options*.**label** - the scale’s label
249+
* *options*.**ticks** - the desired number of ticks, or an array of tick values
250+
* *options*.**tickFormat** - a format function for the legend’s ticks
251+
* *options*.**tickSize** - the tick size
252+
* *options*.**round** - if true (default), round tick positions to pixels
253+
* *options*.**width** - the legend’s width
254+
* *options*.**height** - the legend’s height
255+
* *options*.**marginTop** - the legend’s top margin
256+
* *options*.**marginRight** - the legend’s right margin
257+
* *options*.**marginBottom** - the legend’s bottom margin
258+
* *options*.**marginLeft** - the legend’s left margin
259+
260+
#### Plot.legend({[*name*]: *scale*, ...*options*})
261+
262+
Returns a legend for the given *scale* definition, passing the options described in the previous section. Currently supports only *color* and *opacity* scales. An opacity scale is treated as a color scale with varying transparency.
263+
228264
### Position options
229265

230266
The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
@@ -274,7 +310,7 @@ Plot automatically generates axes for position scales. You can configure these a
274310
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
275311
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)
276312

277-
Plot does not currently generate a legend for the *color*, *radius*, or *opacity* scales, but when it does, we expect that some of the above options will also be used to configure legends. Top-level options are also supported as shorthand: **grid** and **line** (for *x* and *y* only; see also [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.
313+
Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.
278314

279315
### Color options
280316

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"devDependencies": {
3838
"@rollup/plugin-json": "^4.1.0",
3939
"@rollup/plugin-node-resolve": "^13.0.4",
40+
"canvas": "^2.8.0",
4041
"eslint": "^7.12.1",
4142
"htl": "^0.3.0",
4243
"js-beautify": "^1.13.0",

src/axis.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,19 @@ function gridFacetY(index, fx, tx) {
202202
.attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join(""));
203203
}
204204

205-
function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
206-
if (!scale.tickFormat && typeof tickFormat !== "function") {
207-
// D3 doesn’t provide a tick format for ordinal scales; we want shorthand
208-
// when an ordinal domain is numbers or dates, and we want null to mean the
209-
// empty string, not the default identity format.
210-
tickFormat = tickFormat === undefined ? (isTemporal(scale.domain()) ? formatIsoDate : string)
211-
: (typeof tickFormat === "string" ? (isTemporal(scale.domain()) ? utcFormat : format)
205+
// D3 doesn’t provide a tick format for ordinal scales; we want shorthand when
206+
// an ordinal domain is numbers or dates, and we want null to mean the empty
207+
// string, not the default identity format.
208+
export function maybeTickFormat(tickFormat, domain) {
209+
return tickFormat === undefined ? (isTemporal(domain) ? formatIsoDate : string)
210+
: typeof tickFormat === "function" ? tickFormat
211+
: (typeof tickFormat === "string" ? (isTemporal(domain) ? utcFormat : format)
212212
: constant)(tickFormat);
213+
}
214+
215+
function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
216+
if (!scale.tickFormat) {
217+
tickFormat = maybeTickFormat(tickFormat, scale.domain());
213218
}
214219
return axis(scale)
215220
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export {window, windowX, windowY} from "./transforms/window.js";
2222
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
2323
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
2424
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
25+
export {legend} from "./legends.js";

src/legends.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {normalizeScale} from "./scales.js";
2+
import {legendColor} from "./legends/color.js";
3+
import {legendOpacity} from "./legends/opacity.js";
4+
import {isObject} from "./mark.js";
5+
6+
const legendRegistry = new Map([
7+
["color", legendColor],
8+
["opacity", legendOpacity]
9+
]);
10+
11+
export function legend(options = {}) {
12+
for (const [key, value] of legendRegistry) {
13+
const scale = options[key];
14+
if (isObject(scale)) { // e.g., ignore {color: "red"}
15+
return value(normalizeScale(key, scale), legendOptions(scale, options));
16+
}
17+
}
18+
throw new Error("unknown legend type");
19+
}
20+
21+
export function exposeLegends(scales, defaults = {}) {
22+
return (key, options) => {
23+
if (!legendRegistry.has(key)) throw new Error(`unknown legend type: ${key}`);
24+
if (!(key in scales)) return;
25+
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options));
26+
};
27+
}
28+
29+
function legendOptions({label, ticks, tickFormat} = {}, options = {}) {
30+
return {label, ticks, tickFormat, ...options};
31+
}
32+
33+
export function Legends(scales, options) {
34+
const legends = [];
35+
for (const [key, value] of legendRegistry) {
36+
const o = options[key];
37+
if (o && o.legend) {
38+
legends.push(value(scales[key], legendOptions(scales[key], o)));
39+
}
40+
}
41+
return legends;
42+
}

src/legends/color.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {legendRamp} from "./ramp.js";
2+
import {legendSwatches} from "./swatches.js";
3+
4+
export function legendColor(color, {
5+
legend = true,
6+
...options
7+
}) {
8+
if (legend === true) legend = color.type === "ordinal" ? "swatches" : "ramp";
9+
switch (`${legend}`.toLowerCase()) {
10+
case "swatches": return legendSwatches(color, options);
11+
case "ramp": return legendRamp(color, options);
12+
default: throw new Error(`unknown legend type: ${legend}`);
13+
}
14+
}

src/legends/opacity.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {rgb} from "d3";
2+
import {legendColor} from "./color.js";
3+
4+
const black = rgb(0, 0, 0);
5+
6+
export function legendOpacity({type, interpolate, ...scale}, {
7+
legend = true,
8+
color = black,
9+
...options
10+
}) {
11+
if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
12+
if (legend === true) legend = "ramp";
13+
if (`${legend}`.toLowerCase() !== "ramp") throw new Error(`${legend} opacity legends are not supported`);
14+
return legendColor({type, ...scale, interpolate: interpolateOpacity(color)}, {legend, ...options});
15+
}
16+
17+
function interpolateOpacity(color) {
18+
const {r, g, b} = rgb(color) || black; // treat invalid color as black
19+
return t => `rgba(${r},${g},${b},${t})`;
20+
}

src/legends/ramp.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {create, quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
2+
import {interpolatePiecewise} from "../scales/quantitative.js";
3+
import {applyInlineStyles, maybeClassName} from "../style.js";
4+
5+
export function legendRamp(color, {
6+
label,
7+
tickSize = 6,
8+
width = 240,
9+
height = 44 + tickSize,
10+
marginTop = 18,
11+
marginRight = 0,
12+
marginBottom = 16 + tickSize,
13+
marginLeft = 0,
14+
style,
15+
ticks = (width - marginLeft - marginRight) / 64,
16+
tickFormat,
17+
round = true,
18+
className
19+
}) {
20+
className = maybeClassName(className);
21+
22+
const svg = create("svg")
23+
.attr("class", className)
24+
.attr("font-family", "system-ui, sans-serif")
25+
.attr("font-size", 10)
26+
.attr("font-variant", "tabular-nums")
27+
.attr("width", width)
28+
.attr("height", height)
29+
.attr("viewBox", `0 0 ${width} ${height}`)
30+
.call(svg => svg.append("style").text(`
31+
.${className} {
32+
display: block;
33+
background: white;
34+
height: auto;
35+
height: intrinsic;
36+
max-width: 100%;
37+
overflow: visible;
38+
}
39+
.${className} text {
40+
white-space: pre;
41+
}
42+
`))
43+
.call(applyInlineStyles, style);
44+
45+
let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
46+
47+
let x;
48+
49+
// Some D3 scales use scale.interpolate, some scale.interpolator, and some
50+
// scale.round; this normalizes the API so it works with all scale types.
51+
const applyRange = round
52+
? (x, range) => x.rangeRound(range)
53+
: (x, range) => x.range(range);
54+
55+
const {type, domain, range, interpolate, scale, pivot} = color;
56+
57+
// Continuous
58+
if (interpolate) {
59+
60+
// Often interpolate is a “fixed” interpolator on the [0, 1] interval, as
61+
// with a built-in color scheme, but sometimes it is a function that takes
62+
// two arguments and is used in conjunction with the range.
63+
const interpolator = range === undefined ? interpolate
64+
: piecewise(interpolate.length === 1 ? interpolatePiecewise(interpolate)
65+
: interpolate, range);
66+
67+
// Construct a D3 scale of the same type, but with a range that evenly
68+
// divides the horizontal extent of the legend. (In the common case, the
69+
// domain.length is two, and so the range is simply the extent.) For a
70+
// diverging scale, we need an extra point in the range for the pivot such
71+
// that the pivot is always drawn in the middle.
72+
x = applyRange(
73+
scale.copy(),
74+
quantize(
75+
interpolateNumber(marginLeft, width - marginRight),
76+
Math.min(
77+
domain.length + (pivot !== undefined),
78+
range === undefined ? Infinity : range.length
79+
)
80+
)
81+
);
82+
83+
svg.append("image")
84+
.attr("x", marginLeft)
85+
.attr("y", marginTop)
86+
.attr("width", width - marginLeft - marginRight)
87+
.attr("height", height - marginTop - marginBottom)
88+
.attr("preserveAspectRatio", "none")
89+
.attr("xlink:href", ramp(interpolator).toDataURL());
90+
}
91+
92+
// Threshold
93+
else if (type === "threshold") {
94+
const thresholds = domain;
95+
96+
const thresholdFormat
97+
= tickFormat === undefined ? d => d
98+
: typeof tickFormat === "string" ? format(tickFormat)
99+
: tickFormat;
100+
101+
// Construct a linear scale with evenly-spaced ticks for each of the
102+
// thresholds; the domain extends one beyond the threshold extent.
103+
x = applyRange(scaleLinear().domain([-1, range.length - 1]), [marginLeft, width - marginRight]);
104+
105+
svg.append("g")
106+
.selectAll("rect")
107+
.data(range)
108+
.join("rect")
109+
.attr("x", (d, i) => x(i - 1))
110+
.attr("y", marginTop)
111+
.attr("width", (d, i) => x(i) - x(i - 1))
112+
.attr("height", height - marginTop - marginBottom)
113+
.attr("fill", d => d);
114+
115+
ticks = Array.from(thresholds, (_, i) => i);
116+
tickFormat = i => thresholdFormat(thresholds[i], i);
117+
}
118+
119+
// Ordinal (hopefully!)
120+
else {
121+
x = applyRange(scaleBand().domain(domain), [marginLeft, width - marginRight]);
122+
123+
svg.append("g")
124+
.selectAll("rect")
125+
.data(domain)
126+
.join("rect")
127+
.attr("x", x)
128+
.attr("y", marginTop)
129+
.attr("width", Math.max(0, x.bandwidth() - 1))
130+
.attr("height", height - marginTop - marginBottom)
131+
.attr("fill", scale);
132+
133+
tickAdjust = () => {};
134+
}
135+
136+
svg.append("g")
137+
.attr("transform", `translate(0,${height - marginBottom})`)
138+
.call(axisBottom(x)
139+
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined)
140+
.tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
141+
.tickSize(tickSize)
142+
.tickValues(Array.isArray(ticks) ? ticks : null))
143+
.attr("font-size", null)
144+
.attr("font-family", null)
145+
.call(tickAdjust)
146+
.call(g => g.select(".domain").remove())
147+
.call(label === undefined ? () => {} : g => g.append("text")
148+
.attr("x", marginLeft)
149+
.attr("y", marginTop + marginBottom - height - 6)
150+
.attr("fill", "currentColor") // TODO move to stylesheet?
151+
.attr("text-anchor", "start")
152+
.attr("font-weight", "bold")
153+
.text(label));
154+
155+
return svg.node();
156+
}
157+
158+
function ramp(color, n = 256) {
159+
const canvas = create("canvas").attr("width", n).attr("height", 1).node();
160+
const context = canvas.getContext("2d");
161+
for (let i = 0; i < n; ++i) {
162+
context.fillStyle = color(i / (n - 1));
163+
context.fillRect(i, 0, 1, 1);
164+
}
165+
return canvas;
166+
}

0 commit comments

Comments
 (0)