diff --git a/README.md b/README.md
index 92317aa624..f26e5fa1ed 100644
--- a/README.md
+++ b/README.md
@@ -224,6 +224,42 @@ For convenience, an apply method is exposed, which returns the scale’s output
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*).
+### Legends
+
+Given a scale definition, Plot can generate a legend.
+
+#### *chart*.legend(*name*[, *options*])
+
+Returns a suitable legend for the chart’s scale with the given *name*. For now, only *color* legends are supported.
+
+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:
+
+* *options*.**tickFormat** - a format function for the labels
+* *options*.**swatchSize** - the size of the swatch (if square)
+* *options*.**swatchWidth** - the swatches’ width
+* *options*.**swatchHeight** - the swatches’ height
+* *options*.**columns** - the number of swatches per row
+* *options*.**marginLeft** - the legend’s left margin
+* *options*.**className** - a class name, that defaults to a randomly generated string scoping the styles
+
+Continuous color legends are rendered as a ramp, and can be configured with the following options:
+
+* *options*.**label** - the scale’s label
+* *options*.**ticks** - the desired number of ticks, or an array of tick values
+* *options*.**tickFormat** - a format function for the legend’s ticks
+* *options*.**tickSize** - the tick size
+* *options*.**round** - if true (default), round tick positions to pixels
+* *options*.**width** - the legend’s width
+* *options*.**height** - the legend’s height
+* *options*.**marginTop** - the legend’s top margin
+* *options*.**marginRight** - the legend’s right margin
+* *options*.**marginBottom** - the legend’s bottom margin
+* *options*.**marginLeft** - the legend’s left margin
+
+#### Plot.legend({[*name*]: *scale*, ...*options*})
+
+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.
+
### Position options
The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
@@ -273,7 +309,7 @@ Plot automatically generates axes for position scales. You can configure these a
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)
-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**.
+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**.
### Color options
diff --git a/package.json b/package.json
index 0fb97b138b..47e553748a 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.0.4",
+ "canvas": "^2.8.0",
"eslint": "^7.12.1",
"htl": "^0.3.0",
"js-beautify": "^1.13.0",
diff --git a/src/axis.js b/src/axis.js
index bdb4720c31..42a39d7686 100644
--- a/src/axis.js
+++ b/src/axis.js
@@ -202,14 +202,19 @@ function gridFacetY(index, fx, tx) {
.attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join(""));
}
-function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
- if (!scale.tickFormat && typeof tickFormat !== "function") {
- // D3 doesn’t provide a tick format for ordinal scales; we want shorthand
- // when an ordinal domain is numbers or dates, and we want null to mean the
- // empty string, not the default identity format.
- tickFormat = tickFormat === undefined ? (isTemporal(scale.domain()) ? formatIsoDate : string)
- : (typeof tickFormat === "string" ? (isTemporal(scale.domain()) ? utcFormat : format)
+// D3 doesn’t provide a tick format for ordinal scales; we want shorthand when
+// an ordinal domain is numbers or dates, and we want null to mean the empty
+// string, not the default identity format.
+export function maybeTickFormat(tickFormat, domain) {
+ return tickFormat === undefined ? (isTemporal(domain) ? formatIsoDate : string)
+ : typeof tickFormat === "function" ? tickFormat
+ : (typeof tickFormat === "string" ? (isTemporal(domain) ? utcFormat : format)
: constant)(tickFormat);
+}
+
+function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
+ if (!scale.tickFormat) {
+ tickFormat = maybeTickFormat(tickFormat, scale.domain());
}
return axis(scale)
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)
diff --git a/src/index.js b/src/index.js
index 28d0645487..74bb524473 100644
--- a/src/index.js
+++ b/src/index.js
@@ -22,3 +22,4 @@ export {window, windowX, windowY} from "./transforms/window.js";
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
+export {legend} from "./legends.js";
diff --git a/src/legends.js b/src/legends.js
new file mode 100644
index 0000000000..8e3a564b5a
--- /dev/null
+++ b/src/legends.js
@@ -0,0 +1,42 @@
+import {normalizeScale} from "./scales.js";
+import {legendColor} from "./legends/color.js";
+import {legendOpacity} from "./legends/opacity.js";
+import {isObject} from "./mark.js";
+
+const legendRegistry = new Map([
+ ["color", legendColor],
+ ["opacity", legendOpacity]
+]);
+
+export function legend(options = {}) {
+ for (const [key, value] of legendRegistry) {
+ const scale = options[key];
+ if (isObject(scale)) { // e.g., ignore {color: "red"}
+ return value(normalizeScale(key, scale), legendOptions(scale, options));
+ }
+ }
+ throw new Error("unknown legend type");
+}
+
+export function exposeLegends(scales, defaults = {}) {
+ return (key, options) => {
+ if (!legendRegistry.has(key)) throw new Error(`unknown legend type: ${key}`);
+ if (!(key in scales)) return;
+ return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options));
+ };
+}
+
+function legendOptions({label, ticks, tickFormat} = {}, options = {}) {
+ return {label, ticks, tickFormat, ...options};
+}
+
+export function Legends(scales, options) {
+ const legends = [];
+ for (const [key, value] of legendRegistry) {
+ const o = options[key];
+ if (o && o.legend) {
+ legends.push(value(scales[key], legendOptions(scales[key], o)));
+ }
+ }
+ return legends;
+}
diff --git a/src/legends/color.js b/src/legends/color.js
new file mode 100644
index 0000000000..c9d87170a3
--- /dev/null
+++ b/src/legends/color.js
@@ -0,0 +1,14 @@
+import {legendRamp} from "./ramp.js";
+import {legendSwatches} from "./swatches.js";
+
+export function legendColor(color, {
+ legend = true,
+ ...options
+}) {
+ if (legend === true) legend = color.type === "ordinal" ? "swatches" : "ramp";
+ switch (`${legend}`.toLowerCase()) {
+ case "swatches": return legendSwatches(color, options);
+ case "ramp": return legendRamp(color, options);
+ default: throw new Error(`unknown legend type: ${legend}`);
+ }
+}
diff --git a/src/legends/opacity.js b/src/legends/opacity.js
new file mode 100644
index 0000000000..7393c36289
--- /dev/null
+++ b/src/legends/opacity.js
@@ -0,0 +1,20 @@
+import {rgb} from "d3";
+import {legendColor} from "./color.js";
+
+const black = rgb(0, 0, 0);
+
+export function legendOpacity({type, interpolate, ...scale}, {
+ legend = true,
+ color = black,
+ ...options
+}) {
+ if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
+ if (legend === true) legend = "ramp";
+ if (`${legend}`.toLowerCase() !== "ramp") throw new Error(`${legend} opacity legends are not supported`);
+ return legendColor({type, ...scale, interpolate: interpolateOpacity(color)}, {legend, ...options});
+}
+
+function interpolateOpacity(color) {
+ const {r, g, b} = rgb(color) || black; // treat invalid color as black
+ return t => `rgba(${r},${g},${b},${t})`;
+}
diff --git a/src/legends/ramp.js b/src/legends/ramp.js
new file mode 100644
index 0000000000..cd7555dce4
--- /dev/null
+++ b/src/legends/ramp.js
@@ -0,0 +1,166 @@
+import {create, quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
+import {interpolatePiecewise} from "../scales/quantitative.js";
+import {applyInlineStyles, maybeClassName} from "../style.js";
+
+export function legendRamp(color, {
+ label,
+ tickSize = 6,
+ width = 240,
+ height = 44 + tickSize,
+ marginTop = 18,
+ marginRight = 0,
+ marginBottom = 16 + tickSize,
+ marginLeft = 0,
+ style,
+ ticks = (width - marginLeft - marginRight) / 64,
+ tickFormat,
+ round = true,
+ className
+}) {
+ className = maybeClassName(className);
+
+ const svg = create("svg")
+ .attr("class", className)
+ .attr("font-family", "system-ui, sans-serif")
+ .attr("font-size", 10)
+ .attr("font-variant", "tabular-nums")
+ .attr("width", width)
+ .attr("height", height)
+ .attr("viewBox", `0 0 ${width} ${height}`)
+ .call(svg => svg.append("style").text(`
+ .${className} {
+ display: block;
+ background: white;
+ height: auto;
+ height: intrinsic;
+ max-width: 100%;
+ overflow: visible;
+ }
+ .${className} text {
+ white-space: pre;
+ }
+ `))
+ .call(applyInlineStyles, style);
+
+ let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
+
+ let x;
+
+ // Some D3 scales use scale.interpolate, some scale.interpolator, and some
+ // scale.round; this normalizes the API so it works with all scale types.
+ const applyRange = round
+ ? (x, range) => x.rangeRound(range)
+ : (x, range) => x.range(range);
+
+ const {type, domain, range, interpolate, scale, pivot} = color;
+
+ // Continuous
+ if (interpolate) {
+
+ // Often interpolate is a “fixed” interpolator on the [0, 1] interval, as
+ // with a built-in color scheme, but sometimes it is a function that takes
+ // two arguments and is used in conjunction with the range.
+ const interpolator = range === undefined ? interpolate
+ : piecewise(interpolate.length === 1 ? interpolatePiecewise(interpolate)
+ : interpolate, range);
+
+ // Construct a D3 scale of the same type, but with a range that evenly
+ // divides the horizontal extent of the legend. (In the common case, the
+ // domain.length is two, and so the range is simply the extent.) For a
+ // diverging scale, we need an extra point in the range for the pivot such
+ // that the pivot is always drawn in the middle.
+ x = applyRange(
+ scale.copy(),
+ quantize(
+ interpolateNumber(marginLeft, width - marginRight),
+ Math.min(
+ domain.length + (pivot !== undefined),
+ range === undefined ? Infinity : range.length
+ )
+ )
+ );
+
+ svg.append("image")
+ .attr("x", marginLeft)
+ .attr("y", marginTop)
+ .attr("width", width - marginLeft - marginRight)
+ .attr("height", height - marginTop - marginBottom)
+ .attr("preserveAspectRatio", "none")
+ .attr("xlink:href", ramp(interpolator).toDataURL());
+ }
+
+ // Threshold
+ else if (type === "threshold") {
+ const thresholds = domain;
+
+ const thresholdFormat
+ = tickFormat === undefined ? d => d
+ : typeof tickFormat === "string" ? format(tickFormat)
+ : tickFormat;
+
+ // Construct a linear scale with evenly-spaced ticks for each of the
+ // thresholds; the domain extends one beyond the threshold extent.
+ x = applyRange(scaleLinear().domain([-1, range.length - 1]), [marginLeft, width - marginRight]);
+
+ svg.append("g")
+ .selectAll("rect")
+ .data(range)
+ .join("rect")
+ .attr("x", (d, i) => x(i - 1))
+ .attr("y", marginTop)
+ .attr("width", (d, i) => x(i) - x(i - 1))
+ .attr("height", height - marginTop - marginBottom)
+ .attr("fill", d => d);
+
+ ticks = Array.from(thresholds, (_, i) => i);
+ tickFormat = i => thresholdFormat(thresholds[i], i);
+ }
+
+ // Ordinal (hopefully!)
+ else {
+ x = applyRange(scaleBand().domain(domain), [marginLeft, width - marginRight]);
+
+ svg.append("g")
+ .selectAll("rect")
+ .data(domain)
+ .join("rect")
+ .attr("x", x)
+ .attr("y", marginTop)
+ .attr("width", Math.max(0, x.bandwidth() - 1))
+ .attr("height", height - marginTop - marginBottom)
+ .attr("fill", scale);
+
+ tickAdjust = () => {};
+ }
+
+ svg.append("g")
+ .attr("transform", `translate(0,${height - marginBottom})`)
+ .call(axisBottom(x)
+ .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined)
+ .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
+ .tickSize(tickSize)
+ .tickValues(Array.isArray(ticks) ? ticks : null))
+ .attr("font-size", null)
+ .attr("font-family", null)
+ .call(tickAdjust)
+ .call(g => g.select(".domain").remove())
+ .call(label === undefined ? () => {} : g => g.append("text")
+ .attr("x", marginLeft)
+ .attr("y", marginTop + marginBottom - height - 6)
+ .attr("fill", "currentColor") // TODO move to stylesheet?
+ .attr("text-anchor", "start")
+ .attr("font-weight", "bold")
+ .text(label));
+
+ return svg.node();
+}
+
+function ramp(color, n = 256) {
+ const canvas = create("canvas").attr("width", n).attr("height", 1).node();
+ const context = canvas.getContext("2d");
+ for (let i = 0; i < n; ++i) {
+ context.fillStyle = color(i / (n - 1));
+ context.fillRect(i, 0, 1, 1);
+ }
+ return canvas;
+}
diff --git a/src/legends/swatches.js b/src/legends/swatches.js
new file mode 100644
index 0000000000..43796c831c
--- /dev/null
+++ b/src/legends/swatches.js
@@ -0,0 +1,102 @@
+import {create} from "d3";
+import {maybeTickFormat} from "../axis.js";
+import {applyInlineStyles, maybeClassName} from "../style.js";
+
+export function legendSwatches(color, {
+ columns,
+ tickFormat,
+ // TODO label,
+ swatchSize = 15,
+ swatchWidth = swatchSize,
+ swatchHeight = swatchSize,
+ marginLeft = 0,
+ className,
+ style,
+ width
+} = {}) {
+ className = maybeClassName(className);
+ tickFormat = maybeTickFormat(tickFormat, color.domain);
+
+ const swatches = create("div")
+ .attr("class", className)
+ .attr("style", `
+ --swatchWidth: ${+swatchWidth}px;
+ --swatchHeight: ${+swatchHeight}px;
+ `);
+
+ let extraStyle;
+
+ if (columns != null) {
+ extraStyle = `
+ .${className}-swatch {
+ display: flex;
+ align-items: center;
+ break-inside: avoid;
+ padding-bottom: 1px;
+ }
+ .${className}-swatch::before {
+ flex-shrink: 0;
+ }
+ .${className}-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ `;
+
+ swatches
+ .style("columns", columns)
+ .selectAll()
+ .data(color.domain)
+ .join("div")
+ .attr("class", `${className}-swatch`)
+ .style("--color", color.scale)
+ .call(item => item.append("div")
+ .attr("class", `${className}-label`)
+ .attr("title", tickFormat)
+ .text(tickFormat));
+ } else {
+ extraStyle = `
+ .${className} {
+ display: flex;
+ align-items: center;
+ min-height: 33px;
+ }
+ .${className}-swatch {
+ display: inline-flex;
+ align-items: center;
+ margin-right: 1em;
+ }
+ `;
+
+ swatches
+ .selectAll()
+ .data(color.domain)
+ .join("span")
+ .attr("class", `${className}-swatch`)
+ .style("--color", color.scale)
+ .text(tickFormat);
+ }
+
+ return swatches
+ .call(div => div.insert("style", "*").text(`
+ .${className} {
+ font-family: system-ui, sans-serif;
+ font-size: 10px;
+ font-variant: tabular-nums;
+ margin-bottom: 0.5em;${marginLeft === undefined ? "" : `
+ margin-left: ${+marginLeft}px;`}${width === undefined ? "" : `
+ width: ${width}px;`}
+ }
+ .${className}-swatch::before {
+ content: "";
+ width: var(--swatchWidth);
+ height: var(--swatchHeight);
+ margin-right: 0.5em;
+ background: var(--color);
+ }
+ ${extraStyle}
+ `))
+ .call(applyInlineStyles, style)
+ .node();
+}
diff --git a/src/mark.js b/src/mark.js
index 4336013c5a..86039ad5f6 100644
--- a/src/mark.js
+++ b/src/mark.js
@@ -173,12 +173,15 @@ export function arrayify(data, type) {
: (data instanceof type ? data : type.from(data)));
}
+// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value.
+export function isObject(option) {
+ return option && option.toString === objectToString;
+}
+
// Disambiguates an options object (e.g., {y: "x2"}) from a channel value
// definition expressed as a channel transform (e.g., {transform: …}).
export function isOptions(option) {
- return option
- && option.toString === objectToString
- && typeof option.transform !== "function";
+ return isObject(option) && typeof option.transform !== "function";
}
// For marks specified either as [0, x] or [x1, x2], such as areas and bars.
diff --git a/src/plot.js b/src/plot.js
index b804f7d200..4ceabf86aa 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -1,9 +1,10 @@
import {create} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {facets} from "./facet.js";
+import {Legends, exposeLegends} from "./legends.js";
import {markify} from "./mark.js";
import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js";
-import {filterStyles, maybeClassName, offset} from "./style.js";
+import {applyInlineStyles, filterStyles, maybeClassName, offset} from "./style.js";
export function plot(options = {}) {
const {facet, style, caption} = options;
@@ -56,13 +57,6 @@ export function plot(options = {}) {
autoScaleLabels(scaleChannels, scaleDescriptors, axes, dimensions, options);
autoAxisTicks(scaleDescriptors, axes);
- // Normalize the options.
- options = {...scaleDescriptors, ...dimensions};
- if (axes.x) options.x = {...options.x, ...axes.x};
- if (axes.y) options.y = {...options.y, ...axes.y};
- if (axes.fx) options.fx = {...options.fx, ...axes.fx};
- if (axes.fy) options.fy = {...options.fy, ...axes.fy};
-
// When faceting, render axes for fx and fy instead of x and y.
const x = facet !== undefined && scales.fx ? "fx" : "x";
const y = facet !== undefined && scales.fy ? "fy" : "y";
@@ -93,10 +87,7 @@ export function plot(options = {}) {
white-space: pre;
}
`))
- .each(function() {
- if (typeof style === "string") this.style = style;
- else Object.assign(this.style, style);
- })
+ .call(applyInlineStyles, style)
.node();
for (const mark of marks) {
@@ -109,14 +100,19 @@ export function plot(options = {}) {
// Wrap the plot in a figure with a caption, if desired.
let figure = svg;
- if (caption != null) {
+ const legends = Legends(scaleDescriptors, options);
+ if (caption != null || legends.length > 0) {
figure = document.createElement("figure");
- figure.appendChild(svg);
- const figcaption = figure.appendChild(document.createElement("figcaption"));
- figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
+ figure.append(...legends, svg);
+ if (caption != null) {
+ const figcaption = document.createElement("figcaption");
+ figcaption.append(caption);
+ figure.append(figcaption);
+ }
}
figure.scale = exposeScales(scaleDescriptors);
+ figure.legend = exposeLegends(scaleDescriptors, options);
return figure;
}
diff --git a/src/scales.js b/src/scales.js
index 480407a81f..47c989bb88 100644
--- a/src/scales.js
+++ b/src/scales.js
@@ -118,6 +118,10 @@ function piecewiseRange(scale) {
return Array.from({length}, (_, i) => start + i / (length - 1) * (end - start));
}
+export function normalizeScale(key, scale) {
+ return Scale(key, undefined, {...scale});
+}
+
function Scale(key, channels = [], options = {}) {
const type = inferScaleType(key, channels, options);
options.type = type; // Mutates input!
@@ -170,7 +174,7 @@ function Scale(key, channels = [], options = {}) {
case "band": return ScaleBand(key, channels, options);
case "identity": return registry.get(key) === position ? ScaleIdentity() : {type: "identity"};
case undefined: return;
- default: throw new Error(`unknown scale type: ${options.type}`);
+ default: throw new Error(`unknown scale type: ${type}`);
}
}
@@ -313,18 +317,19 @@ export function exposeScales(scaleDescriptors) {
function exposeScale({
scale,
type,
+ domain,
range,
label,
interpolate,
transform,
- percent
+ percent,
+ pivot
}) {
if (type === "identity") return {type: "identity", apply: d => d, invert: d => d};
- const domain = scale.domain();
const unknown = scale.unknown ? scale.unknown() : undefined;
return {
type,
- domain,
+ domain: Array.from(domain), // defensive copy
...range !== undefined && {range: Array.from(range)}, // defensive copy
...transform !== undefined && {transform},
...percent && {percent}, // only exposed if truthy
@@ -335,8 +340,8 @@ function exposeScale({
...interpolate !== undefined && {interpolate},
...scale.clamp && {clamp: scale.clamp()},
- // diverging
- ...isDivergingScale({type}) && (([min, pivot, max]) => ({domain: [min, max], pivot}))(domain),
+ // diverging (always asymmetric; we never want to apply the symmetric transform twice)
+ ...pivot !== undefined && {pivot, symmetric: false},
// log, diverging-log
...scale.base && {base: scale.base()},
diff --git a/src/scales/diverging.js b/src/scales/diverging.js
index 081868f394..adf94280f6 100644
--- a/src/scales/diverging.js
+++ b/src/scales/diverging.js
@@ -61,7 +61,7 @@ function ScaleD(key, scale, transform, channels, {
scale.domain([min, pivot, max]).unknown(unknown).interpolator(interpolate);
if (clamp) scale.clamp(clamp);
if (nice) scale.nice(nice);
- return {type, interpolate, scale};
+ return {type, domain: [min, max], pivot, interpolate, scale};
}
export function ScaleDiverging(key, channels, options) {
diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js
index de831962af..fe3b80ddb9 100644
--- a/src/scales/quantitative.js
+++ b/src/scales/quantitative.js
@@ -101,7 +101,7 @@ export function ScaleQ(key, scale, channels, {
if (reverse) domain = reverseof(domain);
scale.domain(domain).unknown(unknown);
- if (nice) scale.nice(nice === true ? undefined : nice);
+ if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain();
if (range !== undefined) scale.range(range);
if (clamp) scale.clamp(clamp);
return {type, domain, range, scale, interpolate};
diff --git a/src/style.js b/src/style.js
index 0fddeb2dfe..8ea03c920c 100644
--- a/src/style.js
+++ b/src/style.js
@@ -162,3 +162,13 @@ export function maybeClassName(name) {
if (!validClassName.test(name)) throw new Error(`invalid class name: ${name}`);
return name;
}
+
+export function applyInlineStyles(selection, style) {
+ if (typeof style === "string") {
+ selection.property("style", style);
+ } else if (style != null) {
+ for (const element of selection) {
+ Object.assign(element.style, style);
+ }
+ }
+}
diff --git a/test/output/caltrain.html b/test/output/caltrain.html
new file mode 100644
index 0000000000..edb323cc21
--- /dev/null
+++ b/test/output/caltrain.html
@@ -0,0 +1,59 @@
+
+
+ NLB
+
+
\ No newline at end of file
diff --git a/test/output/caltrain.svg b/test/output/caltrain.svg
deleted file mode 100644
index 58ff79225a..0000000000
--- a/test/output/caltrain.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
\ No newline at end of file
diff --git a/test/output/colorLegendCategorical.html b/test/output/colorLegendCategorical.html
new file mode 100644
index 0000000000..5bc39f3e2b
--- /dev/null
+++ b/test/output/colorLegendCategorical.html
@@ -0,0 +1,34 @@
+
+ ABCDEFGHIJ
+
\ No newline at end of file
diff --git a/test/output/colorLegendCategoricalColumns.html b/test/output/colorLegendCategoricalColumns.html
new file mode 100644
index 0000000000..13d9b09572
--- /dev/null
+++ b/test/output/colorLegendCategoricalColumns.html
@@ -0,0 +1,66 @@
+
+
+
+
Wholesale and Retail Trade
+
+
+
Manufacturing
+
+
+
Leisure and hospitality
+
+
+
Business services
+
+
+
Construction
+
+
+
Education and Health
+
+
+
Government
+
+
+
Finance
+
+
+
Self-employed
+
+
+
Other
+
+
\ No newline at end of file
diff --git a/test/output/colorLegendCategoricalReverse.html b/test/output/colorLegendCategoricalReverse.html
new file mode 100644
index 0000000000..1d1656e5c0
--- /dev/null
+++ b/test/output/colorLegendCategoricalReverse.html
@@ -0,0 +1,34 @@
+
+ JIHGFEDCBA
+
\ No newline at end of file
diff --git a/test/output/colorLegendCategoricalScheme.html b/test/output/colorLegendCategoricalScheme.html
new file mode 100644
index 0000000000..0c8ff580b9
--- /dev/null
+++ b/test/output/colorLegendCategoricalScheme.html
@@ -0,0 +1,34 @@
+
+ ABCDEFGHIJ
+
\ No newline at end of file
diff --git a/test/output/colorLegendDiverging.svg b/test/output/colorLegendDiverging.svg
new file mode 100644
index 0000000000..4716bb7458
--- /dev/null
+++ b/test/output/colorLegendDiverging.svg
@@ -0,0 +1,34 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendDivergingPivot.svg b/test/output/colorLegendDivergingPivot.svg
new file mode 100644
index 0000000000..0139ad93e9
--- /dev/null
+++ b/test/output/colorLegendDivergingPivot.svg
@@ -0,0 +1,34 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendDivergingPivotAsymmetric.svg b/test/output/colorLegendDivergingPivotAsymmetric.svg
new file mode 100644
index 0000000000..a700862588
--- /dev/null
+++ b/test/output/colorLegendDivergingPivotAsymmetric.svg
@@ -0,0 +1,31 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendDivergingSqrt.svg b/test/output/colorLegendDivergingSqrt.svg
new file mode 100644
index 0000000000..4ba939a562
--- /dev/null
+++ b/test/output/colorLegendDivergingSqrt.svg
@@ -0,0 +1,34 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendInterpolate.svg b/test/output/colorLegendInterpolate.svg
new file mode 100644
index 0000000000..43f0624a78
--- /dev/null
+++ b/test/output/colorLegendInterpolate.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendInterpolateSqrt.svg b/test/output/colorLegendInterpolateSqrt.svg
new file mode 100644
index 0000000000..bcbee1dc2c
--- /dev/null
+++ b/test/output/colorLegendInterpolateSqrt.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLabelBoth.svg b/test/output/colorLegendLabelBoth.svg
new file mode 100644
index 0000000000..1ec7a01450
--- /dev/null
+++ b/test/output/colorLegendLabelBoth.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLabelLegend.svg b/test/output/colorLegendLabelLegend.svg
new file mode 100644
index 0000000000..1ec7a01450
--- /dev/null
+++ b/test/output/colorLegendLabelLegend.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLabelScale.svg b/test/output/colorLegendLabelScale.svg
new file mode 100644
index 0000000000..ec8eca0c82
--- /dev/null
+++ b/test/output/colorLegendLabelScale.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLinear.svg b/test/output/colorLegendLinear.svg
new file mode 100644
index 0000000000..72075720ee
--- /dev/null
+++ b/test/output/colorLegendLinear.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLinearTruncatedScheme.svg b/test/output/colorLegendLinearTruncatedScheme.svg
new file mode 100644
index 0000000000..1da15a2948
--- /dev/null
+++ b/test/output/colorLegendLinearTruncatedScheme.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLog.svg b/test/output/colorLegendLog.svg
new file mode 100644
index 0000000000..ff59cdd0a0
--- /dev/null
+++ b/test/output/colorLegendLog.svg
@@ -0,0 +1,49 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendLogTicks.svg b/test/output/colorLegendLogTicks.svg
new file mode 100644
index 0000000000..74a03d211e
--- /dev/null
+++ b/test/output/colorLegendLogTicks.svg
@@ -0,0 +1,49 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendMargins.svg b/test/output/colorLegendMargins.svg
new file mode 100644
index 0000000000..919a0a928b
--- /dev/null
+++ b/test/output/colorLegendMargins.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinal.html b/test/output/colorLegendOrdinal.html
new file mode 100644
index 0000000000..1640eb7835
--- /dev/null
+++ b/test/output/colorLegendOrdinal.html
@@ -0,0 +1,34 @@
+
+ ABCDEFGHIJ
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalRamp.svg b/test/output/colorLegendOrdinalRamp.svg
new file mode 100644
index 0000000000..ff34647c90
--- /dev/null
+++ b/test/output/colorLegendOrdinalRamp.svg
@@ -0,0 +1,60 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalRampTickSize.svg b/test/output/colorLegendOrdinalRampTickSize.svg
new file mode 100644
index 0000000000..062c577caf
--- /dev/null
+++ b/test/output/colorLegendOrdinalRampTickSize.svg
@@ -0,0 +1,48 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalReverseRamp.svg b/test/output/colorLegendOrdinalReverseRamp.svg
new file mode 100644
index 0000000000..281a9d8ad1
--- /dev/null
+++ b/test/output/colorLegendOrdinalReverseRamp.svg
@@ -0,0 +1,60 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalScheme.html b/test/output/colorLegendOrdinalScheme.html
new file mode 100644
index 0000000000..08587d668e
--- /dev/null
+++ b/test/output/colorLegendOrdinalScheme.html
@@ -0,0 +1,34 @@
+
+ ABCDEFGHIJ
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalSchemeRamp.svg b/test/output/colorLegendOrdinalSchemeRamp.svg
new file mode 100644
index 0000000000..2d56d5bb1d
--- /dev/null
+++ b/test/output/colorLegendOrdinalSchemeRamp.svg
@@ -0,0 +1,60 @@
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalTickFormat.html b/test/output/colorLegendOrdinalTickFormat.html
new file mode 100644
index 0000000000..713ec3e01c
--- /dev/null
+++ b/test/output/colorLegendOrdinalTickFormat.html
@@ -0,0 +1,34 @@
+
+ 1.02.03.04.05.0
+
\ No newline at end of file
diff --git a/test/output/colorLegendOrdinalTickFormatFunction.html b/test/output/colorLegendOrdinalTickFormatFunction.html
new file mode 100644
index 0000000000..713ec3e01c
--- /dev/null
+++ b/test/output/colorLegendOrdinalTickFormatFunction.html
@@ -0,0 +1,34 @@
+