Skip to content

Commit 2ac8d27

Browse files
committed
legends
1 parent ca57bbd commit 2ac8d27

26 files changed

+617
-14
lines changed

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,48 @@ const plot2 = Plot.plot({…, color: plot1.scale("color")});
218218

219219
The returned scale object represents the actual (or “materialized”) values encountered in the plot, including the domain, range, interpolate function, *etc.* The scale’s label, if any, is also returned; however, note that other axis properties are not currently exposed. 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*).
220220

221+
### Legends
222+
223+
Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend:
224+
225+
#### Plot.legend(*options*)
226+
227+
If *options*.**color** is specified as a color scale (or a chart), a suitable color legend is returned, as swatches for categorical and ordinal scales, and as a ramp for continuous scales.
228+
229+
The color swatches can be configured with the following options:
230+
* *options*.**columns** - the number of swatches per row
231+
* *options*.**format** - a format function for the labels
232+
* *options*.**swatchSize** - the size of the swatch (if square)
233+
* *options*.**swatchWidth** - the swatches’ width
234+
* *options*.**swatchHeight** - the swatches’ height
235+
* *options*.**marginLeft** - the legend’s left margin
236+
237+
The continuous color legends can be configured with the following options:
238+
* *options*.**label** - the scale’s label
239+
* *options*.**tickSize** - the tick size
240+
* *options*.**width** - the legend’s width
241+
* *options*.**height** - the legend’s height
242+
* *options*.**marginTop** - the legend’s top margin
243+
* *options*.**marginRight** - the legend’s right margin
244+
* *options*.**marginBottom** - the legend’s bottom margin
245+
* *options*.**marginLeft** - the legend’s left margin
246+
* *options*.**ticks** - number of ticks
247+
* *options*.**tickFormat** - a format function for the legend’s ticks
248+
* *options*.**tickValues** - the legend’s tick values
249+
250+
If *options*.**opacity** is specified as an opacity scale (or a chart), an opacity legend is returned—rendered as a grayscale color legend. The same options as above apply.
251+
252+
If *options*.**r** is specified as a radius scale (or a chart), an radius legend is returned—rendered as circles on a common base.
253+
254+
The radius legend can be configured with the following options:
255+
* *options*.**label** - the scale’s label
256+
* *options*.**ticks** - the number of ticks (circles)
257+
* *options*.**tickFormat** - a format function for the ticks (TODO: format??)
258+
* *options*.**strokeWidth** - the circles’ stroke width, in pixels; default to 0.5
259+
* *options*.**strokeDasharray** - the connector’s stroke dash-array, defaults to [5, 4]
260+
* *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8
261+
* *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels.
262+
221263
### Position options
222264

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

270-
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**.
312+
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**.
271313

272314
### Color options
273315

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"devDependencies": {
3939
"@rollup/plugin-json": "^4.1.0",
4040
"@rollup/plugin-node-resolve": "^13.0.4",
41+
"canvas": "^2.8.0",
4142
"clean-css": "^5.1.1",
4243
"eslint": "^7.12.1",
4344
"htl": "^0.3.0",

src/figure.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
// Wrap the plot in a figure with a caption, if desired.
3+
export function figureWrap(svg, {width}, caption) {
4+
if (caption == null) return svg;
5+
const figure = document.createElement("figure");
6+
figure.style = `max-width: ${width}px`;
7+
figure.appendChild(svg);
8+
const figcaption = document.createElement("figcaption");
9+
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
10+
figure.appendChild(figcaption);
11+
return figure;
12+
}

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {registry} from "./scales/index.js";
2+
import {legendColor} from "./legends/color.js";
3+
import {legendOpacity} from "./legends/opacity.js";
4+
import {legendRadius} from "./legends/radius.js";
5+
6+
export function createLegends(descriptors, dimensions) {
7+
const legends = [];
8+
for (const [key] of registry) {
9+
const scale = descriptors(key);
10+
if (scale === undefined) continue;
11+
let {legend, ...options} = scale;
12+
if (key === "color" && legend === true) legend = legendColor;
13+
if (key === "opacity" && legend === true) legend = legendOpacity;
14+
if (key === "r" && legend === true) legend = legendRadius;
15+
if (typeof legend === "function") {
16+
const l = legend(options, dimensions);
17+
if (l instanceof Node) legends.push(l);
18+
}
19+
}
20+
return legends;
21+
}
22+
23+
export function legend({color, opacity, r, ...options}) {
24+
if (color) return legendColor(plotOrScale(color, "color"), options);
25+
if (r) return legendRadius(plotOrScale(r, "r"), options);
26+
if (opacity) return legendOpacity(plotOrScale(opacity, "opacity"), options);
27+
}
28+
29+
function plotOrScale(p, scale) {
30+
return (typeof p === "object" && "scale" in p && typeof p.scale === "function")
31+
? p.scale(scale)
32+
: p;
33+
}

src/legends/color.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {legendRamp} from "./ramp.js";
2+
import {legendSwatches} from "./swatches.js";
3+
4+
export function legendColor(color, options) {
5+
return color.type === "ordinal" || color.type === "categorical"
6+
? legendSwatches(color, options)
7+
: legendRamp(color, options);
8+
}

src/legends/opacity.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {legendColor} from "./color.js";
2+
3+
export function legendOpacity(opacity, options) {
4+
return legendColor({
5+
...opacity,
6+
domain: [0, 1],
7+
interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})`
8+
// scheme: "greys"
9+
}, options);
10+
}

src/legends/radius.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {plot} from "../plot.js";
2+
import {link} from "../marks/link.js";
3+
import {text} from "../marks/text.js";
4+
import {dot} from "../marks/dot.js";
5+
import {scale} from "../scales.js";
6+
7+
export function legendRadius(r, {
8+
label,
9+
ticks = 5,
10+
tickFormat = (d) => d,
11+
strokeWidth = 0.5,
12+
strokeDasharray = [5, 4],
13+
minStep = 8,
14+
gap = 20
15+
}) {
16+
const s = scale(r);
17+
const r0 = s.range()[1];
18+
19+
const shiftY = label ? 10 : 0;
20+
21+
let h = Infinity;
22+
const values = s
23+
.ticks(ticks)
24+
.reverse()
25+
.filter((t) => h - s(t) > minStep / 2 && (h = s(t)));
26+
27+
return plot({
28+
x: { type: "identity", axis: null },
29+
r: { type: "identity" },
30+
y: { type: "identity", axis: null },
31+
marks: [
32+
link(values, {
33+
x1: r0 + 2,
34+
y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
35+
x2: 2 * r0 + 2 + gap,
36+
y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
37+
strokeWidth: strokeWidth / 2,
38+
strokeDasharray
39+
}),
40+
dot(values, {
41+
r: s,
42+
x: r0 + 2,
43+
y: (d) => 8 + 2 * r0 - s(d) + shiftY,
44+
strokeWidth
45+
}),
46+
text(values, {
47+
x: 2 * r0 + 2 + gap,
48+
y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
49+
textAnchor: "start",
50+
dx: 4,
51+
text: tickFormat
52+
}),
53+
text(label ? [label] : [], {
54+
x: 0,
55+
y: 6,
56+
textAnchor: "start",
57+
fontWeight: "bold",
58+
text: tickFormat
59+
})
60+
],
61+
height: 2 * r0 + 10 + shiftY
62+
});
63+
}

src/legends/ramp.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {scale} from "../scales.js";
2+
import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3";
3+
4+
export function legendRamp(color, {
5+
label,
6+
tickSize = 6,
7+
width = 240,
8+
height = 44 + tickSize,
9+
marginTop = 18,
10+
marginRight = 0,
11+
marginBottom = 16 + tickSize,
12+
marginLeft = 0,
13+
ticks = width / 64,
14+
tickFormat,
15+
tickValues
16+
} = {}) {
17+
color = scale(color);
18+
const svg = create("svg")
19+
.attr("width", width)
20+
.attr("height", height)
21+
.attr("viewBox", [0, 0, width, height])
22+
.style("overflow", "visible")
23+
.style("display", "block");
24+
25+
let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
26+
let x;
27+
28+
// Continuous
29+
if (color.interpolate) {
30+
const n = Math.min(color.domain().length, color.range().length);
31+
x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n));
32+
let color2 = color.copy().domain(quantize(interpolate(0, 1), n));
33+
// special case for log scales
34+
if (color.base) {
35+
const p = scaleLinear(
36+
quantize(interpolate(0, 1), color.domain().length),
37+
color.domain().map(d => Math.log(d))
38+
);
39+
color2 = t => color(Math.exp(p(t)));
40+
}
41+
svg.append("image")
42+
.attr("x", marginLeft)
43+
.attr("y", marginTop)
44+
.attr("width", width - marginLeft - marginRight)
45+
.attr("height", height - marginTop - marginBottom)
46+
.attr("preserveAspectRatio", "none")
47+
.attr("xlink:href", ramp(color2).toDataURL());
48+
}
49+
50+
// Sequential
51+
else if (color.interpolator) {
52+
x = Object.assign(color.copy()
53+
.interpolator(interpolateRound(marginLeft, width - marginRight)),
54+
{range() { return [marginLeft, width - marginRight]; }});
55+
56+
svg.append("image")
57+
.attr("x", marginLeft)
58+
.attr("y", marginTop)
59+
.attr("width", width - marginLeft - marginRight)
60+
.attr("height", height - marginTop - marginBottom)
61+
.attr("preserveAspectRatio", "none")
62+
.attr("xlink:href", ramp(color.interpolator()).toDataURL());
63+
64+
// scaleSequentialQuantile doesn’t implement ticks or tickFormat.
65+
if (!x.ticks) {
66+
if (tickValues === undefined) {
67+
const n = Math.round(ticks + 1);
68+
tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1)));
69+
}
70+
if (typeof tickFormat !== "function") {
71+
tickFormat = format(tickFormat === undefined ? ",f" : tickFormat);
72+
}
73+
}
74+
}
75+
76+
// Threshold
77+
else if (color.invertExtent) {
78+
const thresholds
79+
= color.thresholds ? color.thresholds() // scaleQuantize
80+
: color.quantiles ? color.quantiles() // scaleQuantile
81+
: color.domain(); // scaleThreshold
82+
83+
const thresholdFormat
84+
= tickFormat === undefined ? d => d
85+
: typeof tickFormat === "string" ? format(tickFormat)
86+
: tickFormat;
87+
88+
x = scaleLinear()
89+
.domain([-1, color.range().length - 1])
90+
.rangeRound([marginLeft, width - marginRight]);
91+
92+
svg.append("g")
93+
.selectAll("rect")
94+
.data(color.range())
95+
.join("rect")
96+
.attr("x", (d, i) => x(i - 1))
97+
.attr("y", marginTop)
98+
.attr("width", (d, i) => x(i) - x(i - 1))
99+
.attr("height", height - marginTop - marginBottom)
100+
.attr("fill", d => d);
101+
102+
tickValues = range(thresholds.length);
103+
tickFormat = i => thresholdFormat(thresholds[i], i);
104+
}
105+
106+
// Ordinal
107+
else {
108+
x = scaleBand()
109+
.domain(color.domain())
110+
.rangeRound([marginLeft, width - marginRight]);
111+
112+
svg.append("g")
113+
.selectAll("rect")
114+
.data(color.domain())
115+
.join("rect")
116+
.attr("x", x)
117+
.attr("y", marginTop)
118+
.attr("width", Math.max(0, x.bandwidth() - 1))
119+
.attr("height", height - marginTop - marginBottom)
120+
.attr("fill", color);
121+
122+
tickAdjust = () => {};
123+
}
124+
125+
svg.append("g")
126+
.attr("transform", `translate(0,${height - marginBottom})`)
127+
.call(axisBottom(x)
128+
.ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined)
129+
.tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
130+
.tickSize(tickSize)
131+
.tickValues(tickValues))
132+
.call(tickAdjust)
133+
.call(g => g.select(".domain").remove())
134+
.call(label === undefined ? () => {}
135+
: g => g.append("text")
136+
.attr("x", marginLeft)
137+
.attr("y", marginTop + marginBottom - height - 6)
138+
.attr("fill", "currentColor")
139+
.attr("text-anchor", "start")
140+
.attr("font-weight", "bold")
141+
.attr("class", "label")
142+
.text(label));
143+
144+
return svg.node();
145+
}
146+
147+
function ramp(color, n = 256) {
148+
const canvas = create("canvas").attr("width", n).attr("height", 1).node();
149+
const context = canvas.getContext("2d");
150+
for (let i = 0; i < n; ++i) {
151+
context.fillStyle = color(i / (n - 1));
152+
context.fillRect(i, 0, 1, 1);
153+
}
154+
return canvas;
155+
}

0 commit comments

Comments
 (0)