Skip to content

Commit 8b686ff

Browse files
committed
max width, label, swatches
1 parent d3c252c commit 8b686ff

20 files changed

+1630
-1137
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,35 @@ Plot automatically generates axes for position scales. You can configure these a
261261
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
262262
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)
263263

264-
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** (for *x* and *y* only; see [facet.grid](#facet-options)), **inset**, **round**, **align**, and **padding**.
264+
265+
Plot can generate a color legend:
266+
267+
* *color*.**legend** - a function that is passed the color scale and the color options, and returns a color legend that will be inserted at the top of the figure.
268+
269+
If *color.legend* is true, a default color legend is created, with swatches for categorical and ordinal scales, and a ramp for continuous scales.
270+
271+
The color swatches can be configured with the following options:
272+
* *color*.**columns** - the number of swatches per row
273+
* *color*.**format** - a format function for the labels
274+
* *color*.**swatchSize** - the size of the swatch (if square)
275+
* *color*.**swatchWidth** - the swatches’ width
276+
* *color*.**swatchHeight** - the swatches’ height
277+
* *color*.**marginLeft** - the legend’s left margin
278+
279+
The continuous color legends can be configured with the following options:
280+
* *color*.**label** - the scale’s label
281+
* *color*.**tickSize** - the tick size
282+
* *color*.**width** - the legend’s width
283+
* *color*.**height** - the legend’s height
284+
* *color*.**marginTop** - the legend’s top margin
285+
* *color*.**marginRight** - the legend’s right margin
286+
* *color*.**marginBottom** - the legend’s bottom margin
287+
* *color*.**marginLeft** - the legend’s left margin
288+
* *color*.**ticks** - number of ticks
289+
* *color*.**tickFormat** - a format function for the legend’s ticks
290+
* *color*.**tickValues** - the legend’s tick values
291+
292+
Plot does not currently generate a legend for the *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** (for *x* and *y* only; see [facet.grid](#facet-options)), **inset**, **round**, **align**, and **padding**.
265293

266294
### Color options
267295

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"devDependencies": {
3333
"@rollup/plugin-json": "^4.1.0",
3434
"@rollup/plugin-node-resolve": "^11.2.1",
35+
"canvas": "^2.8.0",
3536
"clean-css": "^5.1.1",
3637
"eslint": "^7.12.1",
3738
"esm": "^3.2.25",

src/figure.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
2+
// Wrap the plot in a figure with a caption, if desired.
3+
export function figureWrap(svg, caption, legends) {
4+
if (caption == null && legends.length === 0) return svg;
5+
const figure = document.createElement("figure");
6+
if (legends.length > 0) {
7+
const figlegends = document.createElement("div");
8+
figlegends.className = "legends";
9+
figure.appendChild(figlegends);
10+
for (const l of legends) {
11+
if (l instanceof Node) {
12+
figlegends.appendChild(l);
13+
}
14+
}
15+
}
16+
figure.appendChild(svg);
17+
if (caption != null) {
18+
const figcaption = document.createElement("figcaption");
19+
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
20+
figure.appendChild(figcaption);
21+
}
22+
return figure;
23+
}

src/legends.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {colorLegend} from "./legends/color.js";
2+
3+
export function createLegends(descriptors, dimensions) {
4+
const legends = [];
5+
6+
for (let key in descriptors) {
7+
let {legend, ...options} = descriptors[key];
8+
if (key === "color" && legend === true) legend = colorLegend;
9+
if (typeof legend === "function") {
10+
const l = legend(options, dimensions);
11+
if (l instanceof Node) legends.push(l);
12+
}
13+
}
14+
15+
return legends;
16+
}

src/legends/color.js

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

src/legends/swatches.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {create} from "d3";
2+
3+
export function swatches(color, {
4+
columns = null,
5+
format = x => x,
6+
swatchSize = 15,
7+
swatchWidth = swatchSize,
8+
swatchHeight = swatchSize,
9+
marginLeft = 0
10+
} = {}) {
11+
const swatches = create("div")
12+
.classed("swatches", true)
13+
.attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px`);
14+
15+
if (columns !== null) {
16+
const elems = swatches.append("div")
17+
.style("columns", columns);
18+
for (const value of color.domain()) {
19+
const d = elems.append("div").classed("swatch-item", true);
20+
d.append("div")
21+
.classed("swatch-block", true)
22+
.style("background", color(value));
23+
const label = format(value);
24+
d.append("div")
25+
.classed("swatch-label", true)
26+
.text(label)
27+
.attr("title", label.replace(/["&]/g, entity));
28+
}
29+
} else {
30+
swatches
31+
.selectAll()
32+
.data(color.domain())
33+
.join("span")
34+
.classed("swatch", true)
35+
.style("--color", color)
36+
.text(format);
37+
}
38+
return swatches.node();
39+
}
40+
41+
function entity(character) {
42+
return `&#${character.charCodeAt(0).toString()};`;
43+
}

src/plot.js

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {Axes, autoAxisTicks, autoAxisLabels, autoScaleLabel} from "./axes.js";
33
import {facets} from "./facet.js";
44
import {values} from "./mark.js";
55
import {Scales, autoScaleRange, exposeScales} from "./scales.js";
6+
import {figureWrap} from "./figure.js";
7+
import {createLegends} from "./legends.js";
68
import {offset} from "./style.js";
79

810
export function plot(options = {}) {
@@ -91,7 +93,11 @@ export function plot(options = {}) {
9193
if (node != null) svg.appendChild(node);
9294
}
9395

94-
return exposeScales(wrap(svg, {caption}), scaleDescriptors);
96+
const descriptors = exposeScales(scaleDescriptors);
97+
const legends = createLegends(descriptors, dimensions);
98+
const figure = figureWrap(svg, caption, legends);
99+
figure.scales = descriptors;
100+
return figure;
95101
}
96102

97103
function Dimensions(
@@ -140,14 +146,3 @@ function autoHeight({y, fy, fx}) {
140146
const ny = y ? (y.family === "ordinal" ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
141147
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
142148
}
143-
144-
// Wrap the plot in a figure with a caption, if desired.
145-
function wrap(svg, {caption} = {}) {
146-
if (caption == null) return svg;
147-
const figure = document.createElement("figure");
148-
figure.appendChild(svg);
149-
const figcaption = document.createElement("figcaption");
150-
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
151-
figure.appendChild(figcaption);
152-
return figure;
153-
}

src/scales.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function Scale(key, channels = [], options = {}) {
7878
}
7979

8080
export function scale(options) {
81-
return Scale(undefined, undefined, options).scale;
81+
return Scale(options.key, undefined, options).scale;
8282
}
8383

8484
function inferScaleType(key, channels, {type, domain, range}) {
@@ -112,16 +112,16 @@ function asOrdinalType(key, type = "categorical") {
112112
return registry.get(key) === position ? "point" : type;
113113
}
114114

115-
export function exposeScales(figure, scaleDescriptors) {
116-
const scales = figure.scales = {};
115+
export function exposeScales(scaleDescriptors) {
116+
const scales = {};
117117
for (const key in scaleDescriptors) {
118118
let cache;
119119
Object.defineProperty(scales, key, {
120120
enumerable: true,
121121
get: () => cache = cache || exposeScale(scaleDescriptors[key])
122122
});
123123
}
124-
return figure;
124+
return scales;
125125
}
126126

127127
function exposeScale({scale, ...options}) {

0 commit comments

Comments
 (0)