From 8601acc5c7ddd8839fdba3149279100be8e18966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 10:43:51 +0200 Subject: [PATCH 01/72] legends --- README.md | 44 ++++++++- package.json | 1 + src/figure.js | 12 +++ src/index.js | 1 + src/legends.js | 33 +++++++ src/legends/color.js | 8 ++ src/legends/opacity.js | 10 +++ src/legends/radius.js | 63 +++++++++++++ src/legends/ramp.js | 155 ++++++++++++++++++++++++++++++++ src/legends/swatches.js | 99 ++++++++++++++++++++ src/plot.js | 10 +-- src/scales.js | 4 + test/output/figcaption.html | 2 +- test/output/figcaptionHtml.html | 2 +- test/output/legendColor.svg | 38 ++++++++ test/output/legendOpacity.svg | 23 +++++ test/output/legendRadius.svg | 29 ++++++ test/output/legendSwatches.html | 50 +++++++++++ test/plot.js | 16 ++-- test/plots/index.js | 4 + test/plots/legend-color.js | 6 ++ test/plots/legend-opacity.js | 13 +++ test/plots/legend-radius.js | 11 +++ test/plots/legend-swatches.js | 6 ++ yarn.lock | 67 +++++++++++++- 25 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 src/figure.js create mode 100644 src/legends.js create mode 100644 src/legends/color.js create mode 100644 src/legends/opacity.js create mode 100644 src/legends/radius.js create mode 100644 src/legends/ramp.js create mode 100644 src/legends/swatches.js create mode 100644 test/output/legendColor.svg create mode 100644 test/output/legendOpacity.svg create mode 100644 test/output/legendRadius.svg create mode 100644 test/output/legendSwatches.html create mode 100644 test/plots/legend-color.js create mode 100644 test/plots/legend-opacity.js create mode 100644 test/plots/legend-radius.js create mode 100644 test/plots/legend-swatches.js diff --git a/README.md b/README.md index 576f2e9ff2..db3969d1ef 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,48 @@ const plot2 = Plot.plot({…, color: plot1.scale("color")}); 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*). +### Legends + +Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: + +#### Plot.legend(*options*) + +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. + +The color swatches can be configured with the following options: +* *options*.**columns** - the number of swatches per row +* *options*.**format** - 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*.**marginLeft** - the legend’s left margin + +The continuous color legends can be configured with the following options: +* *options*.**label** - the scale’s label +* *options*.**tickSize** - the tick size +* *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 +* *options*.**ticks** - number of ticks +* *options*.**tickFormat** - a format function for the legend’s ticks +* *options*.**tickValues** - the legend’s tick values + +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. + +If *options*.**r** is specified as a radius scale (or a chart), an radius legend is returned—rendered as circles on a common base. + +The radius legend can be configured with the following options: +* *options*.**label** - the scale’s label +* *options*.**ticks** - the number of ticks (circles) +* *options*.**tickFormat** - a format function for the ticks (TODO: format??) +* *options*.**strokeWidth** - the circles’ stroke width, in pixels; default to 0.5 +* *options*.**strokeDasharray** - the connector’s stroke dash-array, defaults to [5, 4] +* *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8 +* *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels. + ### Position options The position scales (*x*, *y*, *fx*, and *fy*) support additional options: @@ -269,7 +311,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/figure.js b/src/figure.js new file mode 100644 index 0000000000..f14884e7b6 --- /dev/null +++ b/src/figure.js @@ -0,0 +1,12 @@ + +// Wrap the plot in a figure with a caption, if desired. +export function figureWrap(svg, {width}, caption) { + if (caption == null) return svg; + const figure = document.createElement("figure"); + figure.style = `max-width: ${width}px`; + figure.appendChild(svg); + const figcaption = document.createElement("figcaption"); + figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); + figure.appendChild(figcaption); + return figure; +} 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..527c75717b --- /dev/null +++ b/src/legends.js @@ -0,0 +1,33 @@ +import {registry} from "./scales/index.js"; +import {legendColor} from "./legends/color.js"; +import {legendOpacity} from "./legends/opacity.js"; +import {legendRadius} from "./legends/radius.js"; + +export function createLegends(descriptors, dimensions) { + const legends = []; + for (const [key] of registry) { + const scale = descriptors(key); + if (scale === undefined) continue; + let {legend, ...options} = scale; + if (key === "color" && legend === true) legend = legendColor; + if (key === "opacity" && legend === true) legend = legendOpacity; + if (key === "r" && legend === true) legend = legendRadius; + if (typeof legend === "function") { + const l = legend(options, dimensions); + if (l instanceof Node) legends.push(l); + } + } + return legends; +} + +export function legend({color, opacity, r, ...options}) { + if (color) return legendColor(plotOrScale(color, "color"), options); + if (r) return legendRadius(plotOrScale(r, "r"), options); + if (opacity) return legendOpacity(plotOrScale(opacity, "opacity"), options); +} + +function plotOrScale(p, scale) { + return (typeof p === "object" && "scale" in p && typeof p.scale === "function") + ? p.scale(scale) + : p; +} diff --git a/src/legends/color.js b/src/legends/color.js new file mode 100644 index 0000000000..5f07e36ccb --- /dev/null +++ b/src/legends/color.js @@ -0,0 +1,8 @@ +import {legendRamp} from "./ramp.js"; +import {legendSwatches} from "./swatches.js"; + +export function legendColor(color, options) { + return color.type === "ordinal" || color.type === "categorical" + ? legendSwatches(color, options) + : legendRamp(color, options); +} diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..e116d3e899 --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,10 @@ +import {legendColor} from "./color.js"; + +export function legendOpacity(opacity, options) { + return legendColor({ + ...opacity, + domain: [0, 1], + interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` + // scheme: "greys" + }, options); +} diff --git a/src/legends/radius.js b/src/legends/radius.js new file mode 100644 index 0000000000..826d10d3ca --- /dev/null +++ b/src/legends/radius.js @@ -0,0 +1,63 @@ +import {plot} from "../plot.js"; +import {link} from "../marks/link.js"; +import {text} from "../marks/text.js"; +import {dot} from "../marks/dot.js"; +import {scale} from "../scales.js"; + +export function legendRadius(r, { + label, + ticks = 5, + tickFormat = (d) => d, + strokeWidth = 0.5, + strokeDasharray = [5, 4], + minStep = 8, + gap = 20 +}) { + const s = scale(r); + const r0 = s.range()[1]; + + const shiftY = label ? 10 : 0; + + let h = Infinity; + const values = s + .ticks(ticks) + .reverse() + .filter((t) => h - s(t) > minStep / 2 && (h = s(t))); + + return plot({ + x: { type: "identity", axis: null }, + r: { type: "identity" }, + y: { type: "identity", axis: null }, + marks: [ + link(values, { + x1: r0 + 2, + y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + x2: 2 * r0 + 2 + gap, + y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + strokeWidth: strokeWidth / 2, + strokeDasharray + }), + dot(values, { + r: s, + x: r0 + 2, + y: (d) => 8 + 2 * r0 - s(d) + shiftY, + strokeWidth + }), + text(values, { + x: 2 * r0 + 2 + gap, + y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + textAnchor: "start", + dx: 4, + text: tickFormat + }), + text(label ? [label] : [], { + x: 0, + y: 6, + textAnchor: "start", + fontWeight: "bold", + text: tickFormat + }) + ], + height: 2 * r0 + 10 + shiftY + }); +} diff --git a/src/legends/ramp.js b/src/legends/ramp.js new file mode 100644 index 0000000000..c53b13f24a --- /dev/null +++ b/src/legends/ramp.js @@ -0,0 +1,155 @@ +import {scale} from "../scales.js"; +import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; + +export function legendRamp(color, { + label, + tickSize = 6, + width = 240, + height = 44 + tickSize, + marginTop = 18, + marginRight = 0, + marginBottom = 16 + tickSize, + marginLeft = 0, + ticks = width / 64, + tickFormat, + tickValues +} = {}) { + color = scale(color); + const svg = create("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .style("overflow", "visible") + .style("display", "block"); + + let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height); + let x; + + // Continuous + if (color.interpolate) { + const n = Math.min(color.domain().length, color.range().length); + x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n)); + let color2 = color.copy().domain(quantize(interpolate(0, 1), n)); + // special case for log scales + if (color.base) { + const p = scaleLinear( + quantize(interpolate(0, 1), color.domain().length), + color.domain().map(d => Math.log(d)) + ); + color2 = t => color(Math.exp(p(t))); + } + 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(color2).toDataURL()); + } + + // Sequential + else if (color.interpolator) { + x = Object.assign(color.copy() + .interpolator(interpolateRound(marginLeft, width - marginRight)), + {range() { return [marginLeft, width - marginRight]; }}); + + 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(color.interpolator()).toDataURL()); + + // scaleSequentialQuantile doesn’t implement ticks or tickFormat. + if (!x.ticks) { + if (tickValues === undefined) { + const n = Math.round(ticks + 1); + tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1))); + } + if (typeof tickFormat !== "function") { + tickFormat = format(tickFormat === undefined ? ",f" : tickFormat); + } + } + } + + // Threshold + else if (color.invertExtent) { + const thresholds + = color.thresholds ? color.thresholds() // scaleQuantize + : color.quantiles ? color.quantiles() // scaleQuantile + : color.domain(); // scaleThreshold + + const thresholdFormat + = tickFormat === undefined ? d => d + : typeof tickFormat === "string" ? format(tickFormat) + : tickFormat; + + x = scaleLinear() + .domain([-1, color.range().length - 1]) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.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); + + tickValues = range(thresholds.length); + tickFormat = i => thresholdFormat(thresholds[i], i); + } + + // Ordinal + else { + x = scaleBand() + .domain(color.domain()) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.domain()) + .join("rect") + .attr("x", x) + .attr("y", marginTop) + .attr("width", Math.max(0, x.bandwidth() - 1)) + .attr("height", height - marginTop - marginBottom) + .attr("fill", color); + + tickAdjust = () => {}; + } + + svg.append("g") + .attr("transform", `translate(0,${height - marginBottom})`) + .call(axisBottom(x) + .ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined) + .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined) + .tickSize(tickSize) + .tickValues(tickValues)) + .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") + .attr("text-anchor", "start") + .attr("font-weight", "bold") + .attr("class", "label") + .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..aa6cceef80 --- /dev/null +++ b/src/legends/swatches.js @@ -0,0 +1,99 @@ +import {scale} from "../scales.js"; +import {create} from "d3"; + +const styles = ` +.plot-swatches { + display: flex; + align-items: center; + margin-left: var(--marginLeft); + min-height: 33px; + font: 10px sans-serif; + margin-bottom: 0.5em; +} + +.plot-swatches > div { + width: 100%; +} + +.plot-swatches .swatch-item { + break-inside: avoid; + display: flex; + align-items: center; + padding-bottom: 1px; +} + +.plot-swatches .swatch-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - var(--swatchWidth) - 0.5em); +} + +.plot-swatches .swatch-block { + width: var(--swatchWidth); + height: var(--swatchHeight); + margin: 0 0.5em 0 0; +} + +.plot-swatch { + display: inline-flex; + align-items: center; + margin-right: 1em; +} + +.plot-swatch::before { + content: ""; + width: var(--swatchWidth); + height: var(--swatchHeight); + margin-right: 0.5em; + background: var(--color); +} +`; + +export function legendSwatches(color, { + columns = null, + format = x => x, + swatchSize = 15, + swatchWidth = swatchSize, + swatchHeight = swatchSize, + marginLeft = 0, + style = styles, + width +} = {}) { + color = scale(color); + const swatches = create("div") + .classed("plot-swatches", true) + .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ + width === undefined ? "" : ` width: ${width}px;` + }`); + swatches.append("style").text(style); + + if (columns !== null) { + const elems = swatches.append("div") + .style("columns", columns); + for (const value of color.domain()) { + const d = elems.append("div").classed("swatch-item", true); + d.append("div") + .classed("swatch-block", true) + .style("background", color(value)); + const label = format(value); + d.append("div") + .classed("swatch-label", true) + .text(label) + .attr("title", label.replace(/["&]/g, entity)); + } + } else { + swatches + .selectAll() + .data(color.domain()) + .join("span") + .classed("plot-swatch", true) + .style("--color", color) + .text(format); + } + return swatches.node(); +} + +function entity(character) { + return `&#${character.charCodeAt(0).toString()};`; +} diff --git a/src/plot.js b/src/plot.js index b804f7d200..9f43d66da6 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; +import {figureWrap} from "./figure.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; @@ -108,14 +109,7 @@ export function plot(options = {}) { } // Wrap the plot in a figure with a caption, if desired. - let figure = svg; - if (caption != null) { - figure = document.createElement("figure"); - figure.appendChild(svg); - const figcaption = figure.appendChild(document.createElement("figcaption")); - figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); - } - + const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); return figure; } diff --git a/src/scales.js b/src/scales.js index 93ab9d7677..ee76a3047f 100644 --- a/src/scales.js +++ b/src/scales.js @@ -60,6 +60,10 @@ export function Scales(channels, { return scales; } +export function scale(options) { + return Scale(options.key, undefined, options).scale; +} + // Mutates scale.range! export function autoScaleRange({x, y, fx, fy}, dimensions) { if (fx) autoScaleRangeX(fx, dimensions); diff --git a/test/output/figcaption.html b/test/output/figcaption.html index 5dd48dad7e..df0b4dab6f 100644 --- a/test/output/figcaption.html +++ b/test/output/figcaption.html @@ -1,4 +1,4 @@ -
+
+ ABC +
\ No newline at end of file diff --git a/test/plot.js b/test/plot.js index f5b8716f58..5e13bd8fc6 100644 --- a/test/plot.js +++ b/test/plot.js @@ -9,12 +9,16 @@ for (const [name, plot] of Object.entries(plots)) { it(`plot ${name}`, async () => { const root = await plot(); const [ext, svg] = root.tagName === "svg" ? ["svg", root] : ["html", root.querySelector("svg")]; - const uid = svg.getAttribute("class"); - svg.setAttribute("class", "plot"); - const style = svg.querySelector("style"); - style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); - svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); - svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); + if (svg) { + svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); + svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); + const uid = svg.getAttribute("class"); + svg.setAttribute("class", "plot"); + const style = svg.querySelector("style"); + if (style) { + style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); + } + } const actual = beautify.html(root.outerHTML, {indent_size: 2}); const outfile = path.resolve("./test/output", `${path.basename(name, ".js")}.${ext}`); const diffile = path.resolve("./test/output", `${path.basename(name, ".js")}-changed.${ext}`); diff --git a/test/plots/index.js b/test/plots/index.js index 9807372a6d..6da6040b79 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -52,6 +52,10 @@ export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; +export {default as legendColor} from "./legend-color.js"; +export {default as legendOpacity} from "./legend-opacity.js"; +export {default as legendRadius} from "./legend-radius.js"; +export {default as legendSwatches} from "./legend-swatches.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js new file mode 100644 index 0000000000..76b099ff48 --- /dev/null +++ b/test/plots/legend-color.js @@ -0,0 +1,6 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const plot = Plot.plot({color: {type: "diverging", domain: [-1, 1] }}); + return Plot.legend({color: plot, width: 500}); +} diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js new file mode 100644 index 0000000000..2aaab20d10 --- /dev/null +++ b/test/plots/legend-opacity.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const chart = Plot.dotX({length: 100}, { + x: Math.random, + y: Math.random, + r: Math.random, + fill: Math.random, + fillOpacity: Math.random + }).plot({ r: { domain: [0, 20], label: "hello, radius" }}); + + return Plot.legend({opacity: chart}); +} diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js new file mode 100644 index 0000000000..9a115e721e --- /dev/null +++ b/test/plots/legend-radius.js @@ -0,0 +1,11 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const chart = Plot.dotX([0, 0.1, 0.2, 0.8, 0.9, 1], { + x: d => d, + r: d => d, + fill: "red" + }).plot({ r: { domain: [0, 20], label: "test radius" }}); + + return Plot.legend({r: chart}); +} diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js new file mode 100644 index 0000000000..a3f5fa3883 --- /dev/null +++ b/test/plots/legend-swatches.js @@ -0,0 +1,6 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const plot = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"] }}); + return Plot.legend({color: plot}); +} diff --git a/yarn.lock b/yarn.lock index e7588e13da..127ffe92bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,6 +69,21 @@ resolved "https://registry.yarnpkg.com/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b" integrity sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950" + integrity sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA== + dependencies: + detect-libc "^1.0.3" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.1" + nopt "^5.0.0" + npmlog "^4.1.2" + rimraf "^3.0.2" + semver "^7.3.4" + tar "^6.1.0" + "@npmcli/arborist@^2.6.4": version "2.10.0" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-2.10.0.tgz#424c2d73a7ae59c960b0cc7f74fed043e4316c2c" @@ -700,6 +715,15 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== +canvas@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461" + integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + nan "^2.14.0" + simple-get "^3.0.3" + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -1242,6 +1266,13 @@ decimal.js@^10.3.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -1300,6 +1331,11 @@ depd@^1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + detect-port@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" @@ -2536,7 +2572,7 @@ magic-string@^0.25.5, magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.4" -make-dir@^3.0.2: +make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -2597,6 +2633,11 @@ mimic-response@^1.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -2740,6 +2781,11 @@ ms@2.1.3, ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nan@^2.14.0: + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + nanoid@3.1.25: version "3.1.25" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.25.tgz#09ca32747c0e543f0e1814b7d3793477f9c8e152" @@ -2760,6 +2806,11 @@ negotiator@^0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-gyp-build@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" @@ -3546,6 +3597,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f" integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + skypack@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/skypack/-/skypack-0.3.2.tgz#9df9fde1ed73ae6874d15111f0636e16f2cab1b9" From 9f3d9cdac5f8b9d1221316c8d7a7470920ec81a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 17:06:43 +0200 Subject: [PATCH 02/72] Plot.legend takes a scale and options --- src/legends.js | 12 +++--------- src/legends/color.js | 8 ++++---- src/legends/opacity.js | 6 +++--- src/legends/radius.js | 5 +++-- src/legends/ramp.js | 5 +++-- src/legends/swatches.js | 7 +++++-- test/output/legendColor.svg | 26 +++++++++++++------------- test/output/legendOpacity.svg | 2 +- test/output/legendRadius.svg | 22 +++++++++++----------- test/plots/legend-color.js | 4 ++-- test/plots/legend-opacity.js | 13 ++++--------- test/plots/legend-radius.js | 4 ++-- test/plots/legend-swatches.js | 4 ++-- 13 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/legends.js b/src/legends.js index 527c75717b..fdb02305d4 100644 --- a/src/legends.js +++ b/src/legends.js @@ -21,13 +21,7 @@ export function createLegends(descriptors, dimensions) { } export function legend({color, opacity, r, ...options}) { - if (color) return legendColor(plotOrScale(color, "color"), options); - if (r) return legendRadius(plotOrScale(r, "r"), options); - if (opacity) return legendOpacity(plotOrScale(opacity, "opacity"), options); -} - -function plotOrScale(p, scale) { - return (typeof p === "object" && "scale" in p && typeof p.scale === "function") - ? p.scale(scale) - : p; + if (color) return legendColor({...color, ...options}); + if (r) return legendRadius({...r, ...options}); + if (opacity) return legendOpacity({...opacity, ...r, ...options}); } diff --git a/src/legends/color.js b/src/legends/color.js index 5f07e36ccb..5a95409c91 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,8 +1,8 @@ import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(color, options) { - return color.type === "ordinal" || color.type === "categorical" - ? legendSwatches(color, options) - : legendRamp(color, options); +export function legendColor(scale) { + return scale.type === "ordinal" || scale.type === "categorical" + ? legendSwatches(scale) + : legendRamp(scale); } diff --git a/src/legends/opacity.js b/src/legends/opacity.js index e116d3e899..c710f97268 100644 --- a/src/legends/opacity.js +++ b/src/legends/opacity.js @@ -1,10 +1,10 @@ import {legendColor} from "./color.js"; -export function legendOpacity(opacity, options) { +export function legendOpacity(scale) { return legendColor({ - ...opacity, + ...scale, domain: [0, 1], interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` // scheme: "greys" - }, options); + }); } diff --git a/src/legends/radius.js b/src/legends/radius.js index 826d10d3ca..53fdab3243 100644 --- a/src/legends/radius.js +++ b/src/legends/radius.js @@ -4,14 +4,15 @@ import {text} from "../marks/text.js"; import {dot} from "../marks/dot.js"; import {scale} from "../scales.js"; -export function legendRadius(r, { +export function legendRadius({ label, ticks = 5, tickFormat = (d) => d, strokeWidth = 0.5, strokeDasharray = [5, 4], minStep = 8, - gap = 20 + gap = 20, + ...r }) { const s = scale(r); const r0 = s.range()[1]; diff --git a/src/legends/ramp.js b/src/legends/ramp.js index c53b13f24a..a8d6fd6696 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,7 +1,7 @@ import {scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; -export function legendRamp(color, { +export function legendRamp({ label, tickSize = 6, width = 240, @@ -12,7 +12,8 @@ export function legendRamp(color, { marginLeft = 0, ticks = width / 64, tickFormat, - tickValues + tickValues, + ...color } = {}) { color = scale(color); const svg = create("svg") diff --git a/src/legends/swatches.js b/src/legends/swatches.js index aa6cceef80..4bc571af16 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,6 +1,8 @@ import {scale} from "../scales.js"; import {create} from "d3"; +// TODO: once we inline, is this smart variable handling any +// better than inline styles? const styles = ` .plot-swatches { display: flex; @@ -50,7 +52,7 @@ const styles = ` } `; -export function legendSwatches(color, { +export function legendSwatches({ columns = null, format = x => x, swatchSize = 15, @@ -58,7 +60,8 @@ export function legendSwatches(color, { swatchHeight = swatchSize, marginLeft = 0, style = styles, - width + width, + ...color } = {}) { color = scale(color); const swatches = create("div") diff --git a/test/output/legendColor.svg b/test/output/legendColor.svg index d2db3ea7d4..5351fdcf31 100644 --- a/test/output/legendColor.svg +++ b/test/output/legendColor.svg @@ -1,38 +1,38 @@ - + - −1.0 + -15°C - −0.8 + -10°C - −0.6 + -5°C - −0.4 + 0°C - −0.2 + +5°C - 0.0 + +10°C - 0.2 + +15°C - 0.4 + +20°C - 0.6 + +25°C - 0.8 + +30°C - 1.0 - + +35°C + temperature \ No newline at end of file diff --git a/test/output/legendOpacity.svg b/test/output/legendOpacity.svg index b236d005bd..c079a3b781 100644 --- a/test/output/legendOpacity.svg +++ b/test/output/legendOpacity.svg @@ -18,6 +18,6 @@ 1.0 - + opaque \ No newline at end of file diff --git a/test/output/legendRadius.svg b/test/output/legendRadius.svg index 94607121ad..2cfa3c8b57 100644 --- a/test/output/legendRadius.svg +++ b/test/output/legendRadius.svg @@ -1,4 +1,4 @@ - + - - - - + + + + - - - - + + + + - 2015105 - + 2015105 + population \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 76b099ff48..111d3ff500 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -1,6 +1,6 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const plot = Plot.plot({color: {type: "diverging", domain: [-1, 1] }}); - return Plot.legend({color: plot, width: 500}); + const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); + return Plot.legend({color: chart.scale("color"), width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); } diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 2aaab20d10..50a6d88381 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -1,13 +1,8 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX({length: 100}, { - x: Math.random, - y: Math.random, - r: Math.random, - fill: Math.random, - fillOpacity: Math.random - }).plot({ r: { domain: [0, 20], label: "hello, radius" }}); - - return Plot.legend({opacity: chart}); + const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { + fillOpacity: "o" + }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); + return Plot.legend({opacity: chart.scale("opacity")}); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index 9a115e721e..f75146c5b7 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -5,7 +5,7 @@ export default async function() { x: d => d, r: d => d, fill: "red" - }).plot({ r: { domain: [0, 20], label: "test radius" }}); + }).plot({ r: { domain: [0, 20], label: "population" }}); - return Plot.legend({r: chart}); + return Plot.legend({r: chart.scale("r")}); } diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js index a3f5fa3883..b602318a3c 100644 --- a/test/plots/legend-swatches.js +++ b/test/plots/legend-swatches.js @@ -1,6 +1,6 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const plot = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"] }}); - return Plot.legend({color: plot}); + const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); + return Plot.legend({color: chart.scale("color")}); } From 3398e54bfceb47f56acc1dd8cf6d9c8e837d3c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 17:22:47 +0200 Subject: [PATCH 03/72] Remove Plot.legend, use chart.legend instead. --- README.md | 16 ++++++++-------- src/index.js | 1 - src/plot.js | 2 ++ test/plots/legend-color.js | 2 +- test/plots/legend-opacity.js | 2 +- test/plots/legend-radius.js | 2 +- test/plots/legend-swatches.js | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index db3969d1ef..b113fad529 100644 --- a/README.md +++ b/README.md @@ -224,11 +224,13 @@ The returned scale object represents the actual (or “materialized”) values e Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: -#### Plot.legend(*options*) +#### chart.legend(*name*[, *options*]) -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. +A suitable legend is returned for the chart’s scale name: *color*, *r*, or *opacity*. -The color swatches can be configured with the following options: +Categorical and ordinal color legends are rendered as swatches. + +The swatches can be configured with the following options: * *options*.**columns** - the number of swatches per row * *options*.**format** - a format function for the labels * *options*.**swatchSize** - the size of the swatch (if square) @@ -236,7 +238,7 @@ The color swatches can be configured with the following options: * *options*.**swatchHeight** - the swatches’ height * *options*.**marginLeft** - the legend’s left margin -The continuous color legends can be configured with the following options: +Continuous color legends are rendered as a ramp, and can be configured with the following options: * *options*.**label** - the scale’s label * *options*.**tickSize** - the tick size * *options*.**width** - the legend’s width @@ -249,11 +251,9 @@ The continuous color legends can be configured with the following options: * *options*.**tickFormat** - a format function for the legend’s ticks * *options*.**tickValues** - the legend’s tick values -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. - -If *options*.**r** is specified as a radius scale (or a chart), an radius legend is returned—rendered as circles on a common base. +An opacity legend is rendered as a grayscale color legend. The same options as above apply. -The radius legend can be configured with the following options: +The r legend is rendered as circles on a common base. It can be configured with the following options: * *options*.**label** - the scale’s label * *options*.**ticks** - the number of ticks (circles) * *options*.**tickFormat** - a format function for the ticks (TODO: format??) diff --git a/src/index.js b/src/index.js index 74bb524473..28d0645487 100644 --- a/src/index.js +++ b/src/index.js @@ -22,4 +22,3 @@ 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/plot.js b/src/plot.js index 9f43d66da6..9e9805c6c4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -2,6 +2,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; import {figureWrap} from "./figure.js"; +import { legend } from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; @@ -111,6 +112,7 @@ export function plot(options = {}) { // Wrap the plot in a figure with a caption, if desired. const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); + figure.legend = (type, options = {}) => legend({[type]: figure.scale(type), ...options}); return figure; } diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 111d3ff500..8d19e3ce5a 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -2,5 +2,5 @@ import * as Plot from "@observablehq/plot"; export default async function() { const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); - return Plot.legend({color: chart.scale("color"), width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); + return chart.legend("color", {width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); } diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 50a6d88381..3459f0de8a 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -4,5 +4,5 @@ export default async function() { const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { fillOpacity: "o" }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); - return Plot.legend({opacity: chart.scale("opacity")}); + return chart.legend("opacity"); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index f75146c5b7..c51ae54c23 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -7,5 +7,5 @@ export default async function() { fill: "red" }).plot({ r: { domain: [0, 20], label: "population" }}); - return Plot.legend({r: chart.scale("r")}); + return chart.legend("r"); } diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js index b602318a3c..1717d5a595 100644 --- a/test/plots/legend-swatches.js +++ b/test/plots/legend-swatches.js @@ -2,5 +2,5 @@ import * as Plot from "@observablehq/plot"; export default async function() { const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); - return Plot.legend({color: chart.scale("color")}); + return chart.legend("color"); } From d1b9b812a8b4f37f4ffdda5f74f11640c1df8359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Oct 2021 11:33:39 +0200 Subject: [PATCH 04/72] reinstate Plot.legend --- README.md | 4 ++++ src/index.js | 1 + src/legends.js | 2 +- src/legends/color.js | 8 +++++--- src/legends/opacity.js | 7 +------ src/legends/radius.js | 6 +++--- src/legends/ramp.js | 4 +--- src/legends/swatches.js | 4 +--- src/scales.js | 6 +----- test/output/legendDirect.svg | 38 +++++++++++++++++++++++++++++++++++ test/output/legendOpacity.svg | 32 ++++++++++++++++++++--------- test/output/legendRadius.svg | 16 +++++++-------- test/plots/index.js | 1 + test/plots/legend-direct.js | 13 ++++++++++++ test/plots/legend-opacity.js | 4 ++-- test/plots/legend-radius.js | 4 ++-- 16 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 test/output/legendDirect.svg create mode 100644 test/plots/legend-direct.js diff --git a/README.md b/README.md index b113fad529..1858118e01 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ The r legend is rendered as circles on a common base. It can be configured with * *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8 * *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels. +#### Plot.legend({*name*: *scale*, ...*options*}) + +Builds a legend from a scale description object, passing the options described in the previous section. The name can be one of *color*, *opacity*, *r*, *x*, *y*, *fx*, *fy*. + ### Position options The position scales (*x*, *y*, *fx*, and *fy*) support additional options: 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 index fdb02305d4..99623c615f 100644 --- a/src/legends.js +++ b/src/legends.js @@ -23,5 +23,5 @@ export function createLegends(descriptors, dimensions) { export function legend({color, opacity, r, ...options}) { if (color) return legendColor({...color, ...options}); if (r) return legendRadius({...r, ...options}); - if (opacity) return legendOpacity({...opacity, ...r, ...options}); + if (opacity) return legendOpacity({...opacity, ...options}); } diff --git a/src/legends/color.js b/src/legends/color.js index 5a95409c91..e4ff70e17f 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,8 +1,10 @@ +import {Scale} from "../scales.js"; import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(scale) { +export function legendColor(options) { + const scale = Scale("color", undefined, options); return scale.type === "ordinal" || scale.type === "categorical" - ? legendSwatches(scale) - : legendRamp(scale); + ? legendSwatches({...scale, ...options}) + : legendRamp({...scale, ...options}); } diff --git a/src/legends/opacity.js b/src/legends/opacity.js index c710f97268..02de10910f 100644 --- a/src/legends/opacity.js +++ b/src/legends/opacity.js @@ -1,10 +1,5 @@ import {legendColor} from "./color.js"; export function legendOpacity(scale) { - return legendColor({ - ...scale, - domain: [0, 1], - interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` - // scheme: "greys" - }); + return legendColor({...scale, interpolate: t => `rgba(0,0,0,${t})`}); } diff --git a/src/legends/radius.js b/src/legends/radius.js index 53fdab3243..52e104e9cd 100644 --- a/src/legends/radius.js +++ b/src/legends/radius.js @@ -1,8 +1,8 @@ +import {Scale} from "../scales.js"; import {plot} from "../plot.js"; import {link} from "../marks/link.js"; import {text} from "../marks/text.js"; import {dot} from "../marks/dot.js"; -import {scale} from "../scales.js"; export function legendRadius({ label, @@ -12,9 +12,9 @@ export function legendRadius({ strokeDasharray = [5, 4], minStep = 8, gap = 20, - ...r + ...options }) { - const s = scale(r); + const s = Scale("r", undefined, options).scale; const r0 = s.range()[1]; const shiftY = label ? 10 : 0; diff --git a/src/legends/ramp.js b/src/legends/ramp.js index a8d6fd6696..1d0d481f84 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,4 +1,3 @@ -import {scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; export function legendRamp({ @@ -13,9 +12,8 @@ export function legendRamp({ ticks = width / 64, tickFormat, tickValues, - ...color + scale: color } = {}) { - color = scale(color); const svg = create("svg") .attr("width", width) .attr("height", height) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 4bc571af16..663dc2e3f3 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,4 +1,3 @@ -import {scale} from "../scales.js"; import {create} from "d3"; // TODO: once we inline, is this smart variable handling any @@ -61,9 +60,8 @@ export function legendSwatches({ marginLeft = 0, style = styles, width, - ...color + scale: color } = {}) { - color = scale(color); const swatches = create("div") .classed("plot-swatches", true) .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ diff --git a/src/scales.js b/src/scales.js index ee76a3047f..ccae3f5eb7 100644 --- a/src/scales.js +++ b/src/scales.js @@ -60,10 +60,6 @@ export function Scales(channels, { return scales; } -export function scale(options) { - return Scale(options.key, undefined, options).scale; -} - // Mutates scale.range! export function autoScaleRange({x, y, fx, fy}, dimensions) { if (fx) autoScaleRangeX(fx, dimensions); @@ -122,7 +118,7 @@ function piecewiseRange(scale) { return Array.from({length}, (_, i) => start + i / (length - 1) * (end - start)); } -function Scale(key, channels = [], options = {}) { +export function Scale(key, channels = [], options = {}) { const type = inferScaleType(key, channels, options); options.type = type; // Mutates input! diff --git a/test/output/legendDirect.svg b/test/output/legendDirect.svg new file mode 100644 index 0000000000..5351fdcf31 --- /dev/null +++ b/test/output/legendDirect.svg @@ -0,0 +1,38 @@ + + + + + -15°C + + + -10°C + + + -5°C + + + 0°C + + + +5°C + + + +10°C + + + +15°C + + + +20°C + + + +25°C + + + +30°C + + + +35°C + temperature + + \ No newline at end of file diff --git a/test/output/legendOpacity.svg b/test/output/legendOpacity.svg index c079a3b781..24a581df86 100644 --- a/test/output/legendOpacity.svg +++ b/test/output/legendOpacity.svg @@ -1,23 +1,35 @@ - + - 0.0 + 1 - - 0.2 + + 2 - - 0.4 + + 3 - 0.6 + - - 0.8 + + + + + + + + + + + + + + - 1.0 + 10 opaque \ No newline at end of file diff --git a/test/output/legendRadius.svg b/test/output/legendRadius.svg index 2cfa3c8b57..88a463bbba 100644 --- a/test/output/legendRadius.svg +++ b/test/output/legendRadius.svg @@ -14,16 +14,16 @@ - - - + + + - - - + + + - 2015105 - population + 10642 + population (millions) \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 6da6040b79..c1f812a182 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,6 +53,7 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; +export {default as legendDirect} from "./legend-direct.js"; export {default as legendOpacity} from "./legend-opacity.js"; export {default as legendRadius} from "./legend-radius.js"; export {default as legendSwatches} from "./legend-swatches.js"; diff --git a/test/plots/legend-direct.js b/test/plots/legend-direct.js new file mode 100644 index 0000000000..c138ced4c9 --- /dev/null +++ b/test/plots/legend-direct.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + return Plot.legend({ + color: { + scheme: "burd", + domain: [-15, 35], + label: "temperature" + }, + width: 500, + tickFormat: d => `${d > 0 ? "+" : ""}${d}°C` + }); +} diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 3459f0de8a..da83d7eb49 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -1,8 +1,8 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { + const chart = Plot.dotX([{o: 1}, {o: 10}], { fillOpacity: "o" - }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); + }).plot({ opacity: {type: "log", label: "opaque", legend: true}}); return chart.legend("opacity"); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index c51ae54c23..30c670c5f1 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -1,11 +1,11 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX([0, 0.1, 0.2, 0.8, 0.9, 1], { + const chart = Plot.dotX([0, 1e6, 2e6, 8e6, 9e6, 1e7], { x: d => d, r: d => d, fill: "red" - }).plot({ r: { domain: [0, 20], label: "population" }}); + }).plot({ r: {range: [0, 30], label: "population (millions)", transform: d => d * 1e-6}}); return chart.legend("r"); } From dd324991257d1e7f246c38737bf4ddcb3694caaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Oct 2021 19:15:10 +0200 Subject: [PATCH 05/72] unused code --- src/legends.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/legends.js b/src/legends.js index 99623c615f..c51921546e 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,25 +1,7 @@ -import {registry} from "./scales/index.js"; import {legendColor} from "./legends/color.js"; import {legendOpacity} from "./legends/opacity.js"; import {legendRadius} from "./legends/radius.js"; -export function createLegends(descriptors, dimensions) { - const legends = []; - for (const [key] of registry) { - const scale = descriptors(key); - if (scale === undefined) continue; - let {legend, ...options} = scale; - if (key === "color" && legend === true) legend = legendColor; - if (key === "opacity" && legend === true) legend = legendOpacity; - if (key === "r" && legend === true) legend = legendRadius; - if (typeof legend === "function") { - const l = legend(options, dimensions); - if (l instanceof Node) legends.push(l); - } - } - return legends; -} - export function legend({color, opacity, r, ...options}) { if (color) return legendColor({...color, ...options}); if (r) return legendRadius({...r, ...options}); From dbd57f19d2b01aad76f446bebe6f09685e6cf215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:31:31 +0200 Subject: [PATCH 06/72] allow legend: "ramp" as an option --- README.md | 2 +- src/legends/color.js | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1858118e01..50da62c6be 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a A suitable legend is returned for the chart’s scale name: *color*, *r*, or *opacity*. -Categorical and ordinal color legends are rendered as swatches. +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*.**columns** - the number of swatches per row diff --git a/src/legends/color.js b/src/legends/color.js index e4ff70e17f..0fbe274cfc 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -2,9 +2,15 @@ import {Scale} from "../scales.js"; import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(options) { +export function legendColor({legend, ...options}) { const scale = Scale("color", undefined, options); - return scale.type === "ordinal" || scale.type === "categorical" - ? legendSwatches({...scale, ...options}) - : legendRamp({...scale, ...options}); + if (legend === undefined) legend = scale.type === "ordinal" || scale.type === "categorical" ? "swatches" : "ramp"; + switch (legend) { + case "swatches": + return legendSwatches({...scale, ...options}); + case "ramp": + return legendRamp({...scale, ...options}); + default: + throw new Error(`unknown legend type ${legend}`); + } } From 0c7f150389bb60547f4ff10ce0da888205bc04b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:53:21 +0200 Subject: [PATCH 07/72] snapshot test for a lot of color legends --- test/output/legendColor.html | 805 +++++++++++++++++++++++++++++++++++ test/output/legendColor.svg | 38 -- test/plots/legend-color.js | 202 ++++++++- 3 files changed, 1005 insertions(+), 40 deletions(-) create mode 100644 test/output/legendColor.html delete mode 100644 test/output/legendColor.svg diff --git a/test/output/legendColor.html b/test/output/legendColor.html new file mode 100644 index 0000000000..28ae339820 --- /dev/null +++ b/test/output/legendColor.html @@ -0,0 +1,805 @@ +
+
+ ABCDEFGHIJ +
+ + + + + + + + + + + + + + + A + + + B + + + C + + + D + + + E + + + F + + + G + + + H + + + I + + + J + + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + scale label + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + legend label + + + + I feel blue + +
+ DCBA +
+
+ ABCD +
+ + + + + + + + + + + + 200 + + + 800 + + + 1,800 + + + 3,201 + + + 5,001 + + + 7,201 + quantiles! + + + + + + + + + + + + + + + 2.0 + + + 3.0 + + + 4.0 + + + 5.0 + + + 6.0 + + + 7.0 + + + 8.0 + thresholds! + + + + + + 0 + + + 20 + + + 40 + + + 60 + + + 80 + + + 100 + Temperature (°F) + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Speed (kts) + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + + + + + + + + + + + + + 10 + + + 20 + + + 30 + + + 40 + + + 50 + + + + + + + + + + + + + + + 100 + Energy (joules) + + + + + + −100 + + + −50 + + + 0 + + + 50 + + + 100 + Temperature (°C) + + + + + + + + + + + + + + + + + 75 + + + 85 + + + 91 + + + 96 + + + 101 + + + 106 + + + 111 + + + 118 + + + 127 + Height (cm) + + + + + + + + + + + + + + + + 2.5 + + + 3.1 + + + 3.5 + + + 3.9 + + + 6 + + + 7 + + + 8 + + + 9.5 + Unemployment rate (%) + + +
+ <1010-1920-2930-3940-4950-5960-6970-79≥80 +
+ + + + + + + + + + + + + + <10 + + + 10-19 + + + 20-29 + + + 30-39 + + + 40-49 + + + 50-59 + + + 60-69 + + + 70-79 + + + ≥80 + Age (years) + + +
+ blueberriesorangesapples +
+
+ +
+
+
+
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/legendColor.svg b/test/output/legendColor.svg deleted file mode 100644 index 5351fdcf31..0000000000 --- a/test/output/legendColor.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - -15°C - - - -10°C - - - -5°C - - - 0°C - - - +5°C - - - +10°C - - - +15°C - - - +20°C - - - +25°C - - - +30°C - - - +35°C - temperature - - \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 8d19e3ce5a..1bed43ced3 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -1,6 +1,204 @@ +import * as d3 from "d3"; import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); - return chart.legend("color", {width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); + const div = document.createElement("div"); + + const ordinal = Plot.dot("ABCDEFGHIJ", {x: 0, fill: d => d}).plot(); + + for (const l of [ + ordinal.legend("color"), + + ordinal.legend("color", {legend: "ramp"}), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot().legend("color"), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ + color: {label: "scale label"} + }).legend("color"), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ + color: {label: "scale label"} + }).legend("color", {label: "legend label"}), + + Plot.legend({ + color: { + width: 400, + type: "sqrt", + scheme: "blues", + range: [0.25, 1], + label: "I feel blue", + marginLeft: 150, + marginRight: 50 + } + }), + + Plot.legend({ color: { domain: "DCBA", scheme: "rainbow" } }), + + Plot.legend({ color: { domain: "DCBA", reverse: true } }), + + Plot.legend({ + color: Plot.plot({ + marks: [ + Plot.dotX(d3.range(100), { + x: (i) => i, + y: (i) => i ** 2, + fill: (i) => i ** 2 + }) + ], + color: { type: "quantile", scheme: "inferno", quantiles: 7 } + }).scale("color"), + width: 300, + label: "quantiles!", + tickFormat: ",d" + }), + + Plot.legend({ + color: { + type: "threshold", + domain: d3.ticks(2, 8, 5), + scheme: "viridis" + }, + width: 300, + label: "thresholds!", + tickFormat: (d) => d.toFixed(1) + }), + + Plot.legend({ + color: { scheme: "viridis", domain: [0, 100], label: "Temperature (°F)" } + }), + + Plot.legend({ + color: { scheme: "Turbo", type: "sqrt", domain: [0, 1], label: "Speed (kts)" } + }), + + Plot.legend({ + color: { + type: "diverging", + domain: [-0.1, 0.1], + scheme: "PiYG", + label: "Daily change", + tickFormat: "+%" + } + }), + + Plot.legend({ + color: { + type: "diverging-sqrt", + domain: [-0.1, 0.1], + scheme: "RdBu", + label: "Daily change", + tickFormat: "+%" + } + }), + + Plot.legend({ + color: { + type: "log", + domain: [1, 100], + scheme: "Blues", + label: "Energy (joules)", + ticks: 10, + width: 380 + } + }), + + Plot.legend({ + color: { + type: "sqrt", + domain: [-100, 0, 100], + range: ["blue", "white", "red"], + label: "Temperature (°C)", + interpolate: "rgb" + } + }), + + Plot.legend({ + color: { + type: "quantile", + domain: d3.range(1000).map(d3.randomNormal(100, 20)), + scheme: "Spectral", + label: "Height (cm)", + tickFormat: ".0f", + width: 400, + quantiles: 10 + } + }), + + Plot.legend({ + color: { + type: "threshold", + domain: [2.5, 3.1, 3.5, 3.9, 6, 7, 8, 9.5], + scheme: "RdBu", + label: "Unemployment rate (%)", + tickSize: 0 + } + }), + + Plot.legend({ + color: { + domain: [ + "<10", + "10-19", + "20-29", + "30-39", + "40-49", + "50-59", + "60-69", + "70-79", + "≥80" + ], + scheme: "Spectral", + label: "Age (years)", + tickSize: 0 + } + }), + + Plot.legend({ + color: { + domain: [ + "<10", + "10-19", + "20-29", + "30-39", + "40-49", + "50-59", + "60-69", + "70-79", + "≥80" + ], + scheme: "Spectral", + label: "Age (years)", + tickSize: 0, + legend: "ramp", + width: 400 + } + }), + + Plot.legend({ + color: { domain: ["blueberries", "oranges", "apples"], scheme: "category10" } + }), + + Plot.legend({ + color: { + domain: [ + "Wholesale and Retail Trade", + "Manufacturing", + "Leisure and hospitality", + "Business services", + "Construction", + "Education and Health", + "Government", + "Finance", + "Self-employed", + "Other" + ], + columns: "180px", // responsive! + width: 960 + } + }) + + ]) div.appendChild(l); + + return div; } From 2096b247a3248bdd08c3e812840fd45bbef72b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:59:49 +0200 Subject: [PATCH 08/72] no random in unit tests --- test/output/legendColor.html | 14 +++++++------- test/plots/legend-color.js | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 28ae339820..f34f18e31f 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -503,25 +503,25 @@ - 75 + 73 - 85 + 82 - 91 + 90 - 96 + 94 - 101 + 100 - 106 + 105 - 111 + 112 118 diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 1bed43ced3..3ea09785ea 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -116,7 +116,7 @@ export default async function() { Plot.legend({ color: { type: "quantile", - domain: d3.range(1000).map(d3.randomNormal(100, 20)), + domain: d3.range(1000).map(d3.randomNormal.source(d3.randomLcg(42))(100, 20)), scheme: "Spectral", label: "Height (cm)", tickFormat: ".0f", From 9f8574788f722ce31827cd052fd9b110ebd82ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 15:04:08 +0200 Subject: [PATCH 09/72] remove redundant color tests --- test/output/legendDirect.svg | 38 ------------------------- test/output/legendSwatches.html | 50 --------------------------------- test/plots/index.js | 2 -- test/plots/legend-direct.js | 13 --------- test/plots/legend-swatches.js | 6 ---- 5 files changed, 109 deletions(-) delete mode 100644 test/output/legendDirect.svg delete mode 100644 test/output/legendSwatches.html delete mode 100644 test/plots/legend-direct.js delete mode 100644 test/plots/legend-swatches.js diff --git a/test/output/legendDirect.svg b/test/output/legendDirect.svg deleted file mode 100644 index 5351fdcf31..0000000000 --- a/test/output/legendDirect.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - -15°C - - - -10°C - - - -5°C - - - 0°C - - - +5°C - - - +10°C - - - +15°C - - - +20°C - - - +25°C - - - +30°C - - - +35°C - temperature - - \ No newline at end of file diff --git a/test/output/legendSwatches.html b/test/output/legendSwatches.html deleted file mode 100644 index 97bcefdf56..0000000000 --- a/test/output/legendSwatches.html +++ /dev/null @@ -1,50 +0,0 @@ -
- ABC -
\ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index c1f812a182..9a03e56f14 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,10 +53,8 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; -export {default as legendDirect} from "./legend-direct.js"; export {default as legendOpacity} from "./legend-opacity.js"; export {default as legendRadius} from "./legend-radius.js"; -export {default as legendSwatches} from "./legend-swatches.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-direct.js b/test/plots/legend-direct.js deleted file mode 100644 index c138ced4c9..0000000000 --- a/test/plots/legend-direct.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - return Plot.legend({ - color: { - scheme: "burd", - domain: [-15, 35], - label: "temperature" - }, - width: 500, - tickFormat: d => `${d > 0 ? "+" : ""}${d}°C` - }); -} diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js deleted file mode 100644 index 1717d5a595..0000000000 --- a/test/plots/legend-swatches.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); - return chart.legend("color"); -} From b4d8a33087eef63f972d107a2eb82ce31358d642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 15:22:59 +0200 Subject: [PATCH 10/72] accept a label on swatches --- src/legends/swatches.js | 14 ++++- test/output/legendColor.html | 101 ++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 663dc2e3f3..1f4caddf22 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -54,6 +54,7 @@ const styles = ` export function legendSwatches({ columns = null, format = x => x, + label, swatchSize = 15, swatchWidth = swatchSize, swatchHeight = swatchSize, @@ -92,7 +93,18 @@ export function legendSwatches({ .style("--color", color) .text(format); } - return swatches.node(); + + return label == null + ? swatches.node() + : create("div") + .call(div => div.append("div") + .style("font-weight", "bold") + .style("font-family", "sans-serif") + .style("font-size", "10px") + .style("margin", "5px 0 -5px 0") + .text(label)) + .call(div => div.append(() => swatches.node())) + .node(); } function entity(character) { diff --git a/test/output/legendColor.html b/test/output/legendColor.html index f34f18e31f..60bbf6bf9a 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -569,55 +569,58 @@
Unemployment rate (%)
-
- <1010-1920-2930-3940-4950-5960-6970-79≥80 +
+
Age (years)
+
+ <1010-1920-2930-3940-4950-5960-6970-79≥80 +
From 504fb4590338caebe617ce41994e30148060175d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:02:07 +0200 Subject: [PATCH 11/72] className for legendSwatches --- src/legends/swatches.js | 19 ++++++---- test/output/legendColor.html | 72 ++++++++++++++++++------------------ test/plots/legend-color.js | 18 +++++---- 3 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 1f4caddf22..5e37b4f9c9 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,9 +1,10 @@ import {create} from "d3"; +import {maybeClassName} from "../style.js"; // TODO: once we inline, is this smart variable handling any // better than inline styles? -const styles = ` -.plot-swatches { +const styles = uid => ` +.${uid} { display: flex; align-items: center; margin-left: var(--marginLeft); @@ -12,25 +13,25 @@ const styles = ` margin-bottom: 0.5em; } -.plot-swatches > div { +.${uid} > div { width: 100%; } -.plot-swatches .swatch-item { +.${uid} .swatch-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } -.plot-swatches .swatch-label { +.${uid} .swatch-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } -.plot-swatches .swatch-block { +.${uid} .swatch-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; @@ -59,12 +60,14 @@ export function legendSwatches({ swatchWidth = swatchSize, swatchHeight = swatchSize, marginLeft = 0, - style = styles, + className, + uid = maybeClassName(className), + style = styles(uid), width, scale: color } = {}) { const swatches = create("div") - .classed("plot-swatches", true) + .classed(uid, true) .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ width === undefined ? "" : ` width: ${width}px;` }`); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 60bbf6bf9a..8de60e0cf4 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -1,7 +1,7 @@
-
+
DCBA
-
+
blueberriesorangesapples
-
+
- - - - - - - - - - - - - 10642 - population (millions) - \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 9a03e56f14..df64142201 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,8 +53,6 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; -export {default as legendOpacity} from "./legend-opacity.js"; -export {default as legendRadius} from "./legend-radius.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js deleted file mode 100644 index da83d7eb49..0000000000 --- a/test/plots/legend-opacity.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.dotX([{o: 1}, {o: 10}], { - fillOpacity: "o" - }).plot({ opacity: {type: "log", label: "opaque", legend: true}}); - return chart.legend("opacity"); -} diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js deleted file mode 100644 index 30c670c5f1..0000000000 --- a/test/plots/legend-radius.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.dotX([0, 1e6, 2e6, 8e6, 9e6, 1e7], { - x: d => d, - r: d => d, - fill: "red" - }).plot({ r: {range: [0, 30], label: "population (millions)", transform: d => d * 1e-6}}); - - return chart.legend("r"); -} From e14cff853f22826d605b97529040834f9ae3ff59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:16:38 +0200 Subject: [PATCH 13/72] more scope --- src/legends/swatches.js | 4 ++-- test/output/legendColor.html | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 5e37b4f9c9..0510ed176f 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -37,13 +37,13 @@ const styles = uid => ` margin: 0 0.5em 0 0; } -.plot-swatch { +.${uid} .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } -.plot-swatch::before { +.${uid} .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 8de60e0cf4..83ce104a60 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -34,13 +34,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches1 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches1 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -198,13 +198,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches2 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches2 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -248,13 +248,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches3 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches3 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -606,13 +606,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches4 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches4 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -698,13 +698,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches5 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches5 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -748,13 +748,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches6 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches6 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); From 40da32096d62d2fbed2e69dc116ac46451d08663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:26:18 +0200 Subject: [PATCH 14/72] only color is available in this branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0d0d6cae0..e29331d52b 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ The returned scale object represents the actual (or “materialized”) values e ### Legends -Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: +Given a chart’s *color* scale, Plot can generate a legend: #### chart.legend(*name*[, *options*]) From 61257a7c66c623a182576424930b46f341dc345a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 17:36:23 +0200 Subject: [PATCH 15/72] do not expose any class --- src/legends/swatches.js | 30 +++--- test/output/legendColor.html | 174 ++++++++++++++++++++++------------- 2 files changed, 125 insertions(+), 79 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 0510ed176f..9538ace4e5 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -17,39 +17,46 @@ const styles = uid => ` width: 100%; } -.${uid} .swatch-item { +.${uid}-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } -.${uid} .swatch-label { +.${uid}-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } -.${uid} .swatch-block { +.${uid}-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } -.${uid} .plot-swatch { +.${uid}-swatch { display: inline-flex; align-items: center; margin-right: 1em; } -.${uid} .plot-swatch::before { +.${uid}-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } + +.${uid}-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; +} `; export function legendSwatches({ @@ -77,13 +84,13 @@ export function legendSwatches({ const elems = swatches.append("div") .style("columns", columns); for (const value of color.domain()) { - const d = elems.append("div").classed("swatch-item", true); + const d = elems.append("div").classed(`${uid}-item`, true); d.append("div") - .classed("swatch-block", true) + .classed(`${uid}-block`, true) .style("background", color(value)); const label = format(value); d.append("div") - .classed("swatch-label", true) + .classed(`${uid}-label`, true) .text(label) .attr("title", label.replace(/["&]/g, entity)); } @@ -92,7 +99,7 @@ export function legendSwatches({ .selectAll() .data(color.domain()) .join("span") - .classed("plot-swatch", true) + .classed(`${uid}-swatch`, true) .style("--color", color) .text(format); } @@ -101,10 +108,7 @@ export function legendSwatches({ ? swatches.node() : create("div") .call(div => div.append("div") - .style("font-weight", "bold") - .style("font-family", "sans-serif") - .style("font-size", "10px") - .style("margin", "5px 0 -5px 0") + .classed(`${uid}-title`, true) .text(label)) .call(div => div.append(() => swatches.node())) .node(); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 83ce104a60..51d25c9ba0 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -14,40 +14,47 @@ width: 100%; } - .swatches1 .swatch-item { + .swatches1-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches1 .swatch-label { + .swatches1-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches1 .swatch-block { + .swatches1-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches1 .plot-swatch { + .swatches1-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches1 .plot-swatch::before { + .swatches1-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - ABCDEFGHIJ + + .swatches1-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + ABCDEFGHIJ
@@ -178,40 +185,47 @@ width: 100%; } - .swatches2 .swatch-item { + .swatches2-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches2 .swatch-label { + .swatches2-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches2 .swatch-block { + .swatches2-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches2 .plot-swatch { + .swatches2-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches2 .plot-swatch::before { + .swatches2-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - DCBA + + .swatches2-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + DCBA
ABCD + + .swatches3-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + ABCD
@@ -570,7 +591,7 @@
-
Age (years)
+
Age (years)
<1010-1920-2930-3940-4950-5960-6970-79≥80 + + .swatches4-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + <1010-1920-2930-3940-4950-5960-6970-79≥80
@@ -678,40 +706,47 @@ width: 100%; } - .swatches5 .swatch-item { + .swatches5-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches5 .swatch-label { + .swatches5-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches5 .swatch-block { + .swatches5-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches5 .plot-swatch { + .swatches5-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches5 .plot-swatch::before { + .swatches5-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - blueberriesorangesapples + + .swatches5-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + blueberriesorangesapples
-
-
-
Wholesale and Retail Trade
+
+
+
Wholesale and Retail Trade
-
-
-
Manufacturing
+
+
+
Manufacturing
-
-
-
Leisure and hospitality
+
+
+
Leisure and hospitality
-
-
-
Business services
+
+
+
Business services
-
-
-
Construction
+
+
+
Construction
-
-
-
Education and Health
+
+
+
Education and Health
-
-
-
Government
+
+
+
Government
-
-
-
Finance
+
+
+
Finance
-
-
-
Self-employed
+
+
+
Self-employed
-
-
-
Other
+
+
+
Other
From 8803da905a30f01f000db1759e7c1118feb4f97f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:51:59 -0800 Subject: [PATCH 16/72] error on unknown legend type --- src/legends.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/legends.js b/src/legends.js index 43994734a1..f36174c75b 100644 --- a/src/legends.js +++ b/src/legends.js @@ -2,4 +2,5 @@ import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { if (color) return legendColor({...color, ...options}); + throw new Error(`unsupported legend type`); } From 3275764f3e9bed4d495458b8f3a02d03590d09df Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:52:28 -0800 Subject: [PATCH 17/72] categorical is normalized to ordinal --- src/legends/color.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends/color.js b/src/legends/color.js index 0fbe274cfc..840630b956 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -4,7 +4,7 @@ import {legendSwatches} from "./swatches.js"; export function legendColor({legend, ...options}) { const scale = Scale("color", undefined, options); - if (legend === undefined) legend = scale.type === "ordinal" || scale.type === "categorical" ? "swatches" : "ramp"; + if (legend === undefined) legend = scale.type === "ordinal" ? "swatches" : "ramp"; switch (legend) { case "swatches": return legendSwatches({...scale, ...options}); From 087d7ad639efe40041e9274cddf5f27db8453f9c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:52:37 -0800 Subject: [PATCH 18/72] show unknown legend type in error --- src/legends/color.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends/color.js b/src/legends/color.js index 840630b956..3ba6d40332 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -11,6 +11,6 @@ export function legendColor({legend, ...options}) { case "ramp": return legendRamp({...scale, ...options}); default: - throw new Error(`unknown legend type ${legend}`); + throw new Error(`unknown color legend type: ${legend}`); } } From 013a1ff33abc9f085870cbdf4914afb9689f97dd Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:53:03 -0800 Subject: [PATCH 19/72] prEtTieR --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 9e9805c6c4..8a2c893458 100644 --- a/src/plot.js +++ b/src/plot.js @@ -2,7 +2,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; import {figureWrap} from "./figure.js"; -import { legend } from "./legends.js"; +import {legend} from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; From 729b4a2dcf7c39daf26eea9fb5e9cb05b9982eaa Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:53:35 -0800 Subject: [PATCH 20/72] prioritize type; avoid unnecessary default --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 8a2c893458..3145348ca4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -112,7 +112,7 @@ export function plot(options = {}) { // Wrap the plot in a figure with a caption, if desired. const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); - figure.legend = (type, options = {}) => legend({[type]: figure.scale(type), ...options}); + figure.legend = (type, options) => legend({...options, [type]: figure.scale(type)}); return figure; } From 1d700f6e0c5021444aa3d73ab9284421afc65d1f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:54:47 -0800 Subject: [PATCH 21/72] non-nullish, not truthy --- src/legends.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends.js b/src/legends.js index f36174c75b..b86a7780a0 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,6 +1,6 @@ import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { - if (color) return legendColor({...color, ...options}); + if (color != null) return legendColor({...color, ...options}); throw new Error(`unsupported legend type`); } From c226965aecf8ad8b6e06657e84f0dc04773c63a3 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:57:10 -0800 Subject: [PATCH 22/72] =?UTF-8?q?div.append(=E2=80=A6nodes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/plots/legend-color.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 1c39b6f868..cacd7aa63d 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -6,7 +6,7 @@ export default async function() { const ordinal = Plot.dot("ABCDEFGHIJ", {x: 0, fill: d => d}).plot(); - for (const l of [ + div.append( ordinal.legend("color", {className: "swatches1"}), ordinal.legend("color", {legend: "ramp"}), @@ -124,7 +124,7 @@ export default async function() { quantiles: 10 } }), - + Plot.legend({ color: { type: "threshold", @@ -200,7 +200,7 @@ export default async function() { } }) - ]) div.appendChild(l); + ); return div; } From 7bec561878707de0be58338e3426d9d1365f0006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 10:43:51 +0200 Subject: [PATCH 23/72] legends --- README.md | 44 ++++++++- package.json | 1 + src/figure.js | 12 +++ src/index.js | 1 + src/legends.js | 33 +++++++ src/legends/color.js | 8 ++ src/legends/opacity.js | 10 +++ src/legends/radius.js | 63 +++++++++++++ src/legends/ramp.js | 155 ++++++++++++++++++++++++++++++++ src/legends/swatches.js | 99 ++++++++++++++++++++ src/plot.js | 10 +-- src/scales.js | 4 + test/output/figcaption.html | 2 +- test/output/figcaptionHtml.html | 2 +- test/output/legendColor.svg | 38 ++++++++ test/output/legendOpacity.svg | 23 +++++ test/output/legendRadius.svg | 29 ++++++ test/output/legendSwatches.html | 50 +++++++++++ test/plot.js | 16 ++-- test/plots/index.js | 4 + test/plots/legend-color.js | 6 ++ test/plots/legend-opacity.js | 13 +++ test/plots/legend-radius.js | 11 +++ test/plots/legend-swatches.js | 6 ++ yarn.lock | 67 +++++++++++++- 25 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 src/figure.js create mode 100644 src/legends.js create mode 100644 src/legends/color.js create mode 100644 src/legends/opacity.js create mode 100644 src/legends/radius.js create mode 100644 src/legends/ramp.js create mode 100644 src/legends/swatches.js create mode 100644 test/output/legendColor.svg create mode 100644 test/output/legendOpacity.svg create mode 100644 test/output/legendRadius.svg create mode 100644 test/output/legendSwatches.html create mode 100644 test/plots/legend-color.js create mode 100644 test/plots/legend-opacity.js create mode 100644 test/plots/legend-radius.js create mode 100644 test/plots/legend-swatches.js diff --git a/README.md b/README.md index 89aeede9f4..ea66d1b1f1 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,48 @@ 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 chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: + +#### Plot.legend(*options*) + +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. + +The color swatches can be configured with the following options: +* *options*.**columns** - the number of swatches per row +* *options*.**format** - 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*.**marginLeft** - the legend’s left margin + +The continuous color legends can be configured with the following options: +* *options*.**label** - the scale’s label +* *options*.**tickSize** - the tick size +* *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 +* *options*.**ticks** - number of ticks +* *options*.**tickFormat** - a format function for the legend’s ticks +* *options*.**tickValues** - the legend’s tick values + +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. + +If *options*.**r** is specified as a radius scale (or a chart), an radius legend is returned—rendered as circles on a common base. + +The radius legend can be configured with the following options: +* *options*.**label** - the scale’s label +* *options*.**ticks** - the number of ticks (circles) +* *options*.**tickFormat** - a format function for the ticks (TODO: format??) +* *options*.**strokeWidth** - the circles’ stroke width, in pixels; default to 0.5 +* *options*.**strokeDasharray** - the connector’s stroke dash-array, defaults to [5, 4] +* *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8 +* *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels. + ### Position options The position scales (*x*, *y*, *fx*, and *fy*) support additional options: @@ -273,7 +315,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/figure.js b/src/figure.js new file mode 100644 index 0000000000..f14884e7b6 --- /dev/null +++ b/src/figure.js @@ -0,0 +1,12 @@ + +// Wrap the plot in a figure with a caption, if desired. +export function figureWrap(svg, {width}, caption) { + if (caption == null) return svg; + const figure = document.createElement("figure"); + figure.style = `max-width: ${width}px`; + figure.appendChild(svg); + const figcaption = document.createElement("figcaption"); + figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); + figure.appendChild(figcaption); + return figure; +} 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..527c75717b --- /dev/null +++ b/src/legends.js @@ -0,0 +1,33 @@ +import {registry} from "./scales/index.js"; +import {legendColor} from "./legends/color.js"; +import {legendOpacity} from "./legends/opacity.js"; +import {legendRadius} from "./legends/radius.js"; + +export function createLegends(descriptors, dimensions) { + const legends = []; + for (const [key] of registry) { + const scale = descriptors(key); + if (scale === undefined) continue; + let {legend, ...options} = scale; + if (key === "color" && legend === true) legend = legendColor; + if (key === "opacity" && legend === true) legend = legendOpacity; + if (key === "r" && legend === true) legend = legendRadius; + if (typeof legend === "function") { + const l = legend(options, dimensions); + if (l instanceof Node) legends.push(l); + } + } + return legends; +} + +export function legend({color, opacity, r, ...options}) { + if (color) return legendColor(plotOrScale(color, "color"), options); + if (r) return legendRadius(plotOrScale(r, "r"), options); + if (opacity) return legendOpacity(plotOrScale(opacity, "opacity"), options); +} + +function plotOrScale(p, scale) { + return (typeof p === "object" && "scale" in p && typeof p.scale === "function") + ? p.scale(scale) + : p; +} diff --git a/src/legends/color.js b/src/legends/color.js new file mode 100644 index 0000000000..5f07e36ccb --- /dev/null +++ b/src/legends/color.js @@ -0,0 +1,8 @@ +import {legendRamp} from "./ramp.js"; +import {legendSwatches} from "./swatches.js"; + +export function legendColor(color, options) { + return color.type === "ordinal" || color.type === "categorical" + ? legendSwatches(color, options) + : legendRamp(color, options); +} diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..e116d3e899 --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,10 @@ +import {legendColor} from "./color.js"; + +export function legendOpacity(opacity, options) { + return legendColor({ + ...opacity, + domain: [0, 1], + interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` + // scheme: "greys" + }, options); +} diff --git a/src/legends/radius.js b/src/legends/radius.js new file mode 100644 index 0000000000..826d10d3ca --- /dev/null +++ b/src/legends/radius.js @@ -0,0 +1,63 @@ +import {plot} from "../plot.js"; +import {link} from "../marks/link.js"; +import {text} from "../marks/text.js"; +import {dot} from "../marks/dot.js"; +import {scale} from "../scales.js"; + +export function legendRadius(r, { + label, + ticks = 5, + tickFormat = (d) => d, + strokeWidth = 0.5, + strokeDasharray = [5, 4], + minStep = 8, + gap = 20 +}) { + const s = scale(r); + const r0 = s.range()[1]; + + const shiftY = label ? 10 : 0; + + let h = Infinity; + const values = s + .ticks(ticks) + .reverse() + .filter((t) => h - s(t) > minStep / 2 && (h = s(t))); + + return plot({ + x: { type: "identity", axis: null }, + r: { type: "identity" }, + y: { type: "identity", axis: null }, + marks: [ + link(values, { + x1: r0 + 2, + y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + x2: 2 * r0 + 2 + gap, + y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + strokeWidth: strokeWidth / 2, + strokeDasharray + }), + dot(values, { + r: s, + x: r0 + 2, + y: (d) => 8 + 2 * r0 - s(d) + shiftY, + strokeWidth + }), + text(values, { + x: 2 * r0 + 2 + gap, + y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + textAnchor: "start", + dx: 4, + text: tickFormat + }), + text(label ? [label] : [], { + x: 0, + y: 6, + textAnchor: "start", + fontWeight: "bold", + text: tickFormat + }) + ], + height: 2 * r0 + 10 + shiftY + }); +} diff --git a/src/legends/ramp.js b/src/legends/ramp.js new file mode 100644 index 0000000000..c53b13f24a --- /dev/null +++ b/src/legends/ramp.js @@ -0,0 +1,155 @@ +import {scale} from "../scales.js"; +import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; + +export function legendRamp(color, { + label, + tickSize = 6, + width = 240, + height = 44 + tickSize, + marginTop = 18, + marginRight = 0, + marginBottom = 16 + tickSize, + marginLeft = 0, + ticks = width / 64, + tickFormat, + tickValues +} = {}) { + color = scale(color); + const svg = create("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .style("overflow", "visible") + .style("display", "block"); + + let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height); + let x; + + // Continuous + if (color.interpolate) { + const n = Math.min(color.domain().length, color.range().length); + x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n)); + let color2 = color.copy().domain(quantize(interpolate(0, 1), n)); + // special case for log scales + if (color.base) { + const p = scaleLinear( + quantize(interpolate(0, 1), color.domain().length), + color.domain().map(d => Math.log(d)) + ); + color2 = t => color(Math.exp(p(t))); + } + 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(color2).toDataURL()); + } + + // Sequential + else if (color.interpolator) { + x = Object.assign(color.copy() + .interpolator(interpolateRound(marginLeft, width - marginRight)), + {range() { return [marginLeft, width - marginRight]; }}); + + 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(color.interpolator()).toDataURL()); + + // scaleSequentialQuantile doesn’t implement ticks or tickFormat. + if (!x.ticks) { + if (tickValues === undefined) { + const n = Math.round(ticks + 1); + tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1))); + } + if (typeof tickFormat !== "function") { + tickFormat = format(tickFormat === undefined ? ",f" : tickFormat); + } + } + } + + // Threshold + else if (color.invertExtent) { + const thresholds + = color.thresholds ? color.thresholds() // scaleQuantize + : color.quantiles ? color.quantiles() // scaleQuantile + : color.domain(); // scaleThreshold + + const thresholdFormat + = tickFormat === undefined ? d => d + : typeof tickFormat === "string" ? format(tickFormat) + : tickFormat; + + x = scaleLinear() + .domain([-1, color.range().length - 1]) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.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); + + tickValues = range(thresholds.length); + tickFormat = i => thresholdFormat(thresholds[i], i); + } + + // Ordinal + else { + x = scaleBand() + .domain(color.domain()) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.domain()) + .join("rect") + .attr("x", x) + .attr("y", marginTop) + .attr("width", Math.max(0, x.bandwidth() - 1)) + .attr("height", height - marginTop - marginBottom) + .attr("fill", color); + + tickAdjust = () => {}; + } + + svg.append("g") + .attr("transform", `translate(0,${height - marginBottom})`) + .call(axisBottom(x) + .ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined) + .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined) + .tickSize(tickSize) + .tickValues(tickValues)) + .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") + .attr("text-anchor", "start") + .attr("font-weight", "bold") + .attr("class", "label") + .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..aa6cceef80 --- /dev/null +++ b/src/legends/swatches.js @@ -0,0 +1,99 @@ +import {scale} from "../scales.js"; +import {create} from "d3"; + +const styles = ` +.plot-swatches { + display: flex; + align-items: center; + margin-left: var(--marginLeft); + min-height: 33px; + font: 10px sans-serif; + margin-bottom: 0.5em; +} + +.plot-swatches > div { + width: 100%; +} + +.plot-swatches .swatch-item { + break-inside: avoid; + display: flex; + align-items: center; + padding-bottom: 1px; +} + +.plot-swatches .swatch-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - var(--swatchWidth) - 0.5em); +} + +.plot-swatches .swatch-block { + width: var(--swatchWidth); + height: var(--swatchHeight); + margin: 0 0.5em 0 0; +} + +.plot-swatch { + display: inline-flex; + align-items: center; + margin-right: 1em; +} + +.plot-swatch::before { + content: ""; + width: var(--swatchWidth); + height: var(--swatchHeight); + margin-right: 0.5em; + background: var(--color); +} +`; + +export function legendSwatches(color, { + columns = null, + format = x => x, + swatchSize = 15, + swatchWidth = swatchSize, + swatchHeight = swatchSize, + marginLeft = 0, + style = styles, + width +} = {}) { + color = scale(color); + const swatches = create("div") + .classed("plot-swatches", true) + .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ + width === undefined ? "" : ` width: ${width}px;` + }`); + swatches.append("style").text(style); + + if (columns !== null) { + const elems = swatches.append("div") + .style("columns", columns); + for (const value of color.domain()) { + const d = elems.append("div").classed("swatch-item", true); + d.append("div") + .classed("swatch-block", true) + .style("background", color(value)); + const label = format(value); + d.append("div") + .classed("swatch-label", true) + .text(label) + .attr("title", label.replace(/["&]/g, entity)); + } + } else { + swatches + .selectAll() + .data(color.domain()) + .join("span") + .classed("plot-swatch", true) + .style("--color", color) + .text(format); + } + return swatches.node(); +} + +function entity(character) { + return `&#${character.charCodeAt(0).toString()};`; +} diff --git a/src/plot.js b/src/plot.js index b804f7d200..9f43d66da6 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; +import {figureWrap} from "./figure.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; @@ -108,14 +109,7 @@ export function plot(options = {}) { } // Wrap the plot in a figure with a caption, if desired. - let figure = svg; - if (caption != null) { - figure = document.createElement("figure"); - figure.appendChild(svg); - const figcaption = figure.appendChild(document.createElement("figcaption")); - figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); - } - + const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); return figure; } diff --git a/src/scales.js b/src/scales.js index 480407a81f..8d636b6547 100644 --- a/src/scales.js +++ b/src/scales.js @@ -60,6 +60,10 @@ export function Scales(channels, { return scales; } +export function scale(options) { + return Scale(options.key, undefined, options).scale; +} + // Mutates scale.range! export function autoScaleRange({x, y, fx, fy}, dimensions) { if (fx) autoScaleRangeX(fx, dimensions); diff --git a/test/output/figcaption.html b/test/output/figcaption.html index 5dd48dad7e..df0b4dab6f 100644 --- a/test/output/figcaption.html +++ b/test/output/figcaption.html @@ -1,4 +1,4 @@ -
+
+ ABC +
\ No newline at end of file diff --git a/test/plot.js b/test/plot.js index f5b8716f58..5e13bd8fc6 100644 --- a/test/plot.js +++ b/test/plot.js @@ -9,12 +9,16 @@ for (const [name, plot] of Object.entries(plots)) { it(`plot ${name}`, async () => { const root = await plot(); const [ext, svg] = root.tagName === "svg" ? ["svg", root] : ["html", root.querySelector("svg")]; - const uid = svg.getAttribute("class"); - svg.setAttribute("class", "plot"); - const style = svg.querySelector("style"); - style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); - svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); - svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); + if (svg) { + svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); + svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); + const uid = svg.getAttribute("class"); + svg.setAttribute("class", "plot"); + const style = svg.querySelector("style"); + if (style) { + style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); + } + } const actual = beautify.html(root.outerHTML, {indent_size: 2}); const outfile = path.resolve("./test/output", `${path.basename(name, ".js")}.${ext}`); const diffile = path.resolve("./test/output", `${path.basename(name, ".js")}-changed.${ext}`); diff --git a/test/plots/index.js b/test/plots/index.js index 2581646ed6..ecaf76da2e 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -52,6 +52,10 @@ export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; +export {default as legendColor} from "./legend-color.js"; +export {default as legendOpacity} from "./legend-opacity.js"; +export {default as legendRadius} from "./legend-radius.js"; +export {default as legendSwatches} from "./legend-swatches.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js new file mode 100644 index 0000000000..76b099ff48 --- /dev/null +++ b/test/plots/legend-color.js @@ -0,0 +1,6 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const plot = Plot.plot({color: {type: "diverging", domain: [-1, 1] }}); + return Plot.legend({color: plot, width: 500}); +} diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js new file mode 100644 index 0000000000..2aaab20d10 --- /dev/null +++ b/test/plots/legend-opacity.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const chart = Plot.dotX({length: 100}, { + x: Math.random, + y: Math.random, + r: Math.random, + fill: Math.random, + fillOpacity: Math.random + }).plot({ r: { domain: [0, 20], label: "hello, radius" }}); + + return Plot.legend({opacity: chart}); +} diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js new file mode 100644 index 0000000000..9a115e721e --- /dev/null +++ b/test/plots/legend-radius.js @@ -0,0 +1,11 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const chart = Plot.dotX([0, 0.1, 0.2, 0.8, 0.9, 1], { + x: d => d, + r: d => d, + fill: "red" + }).plot({ r: { domain: [0, 20], label: "test radius" }}); + + return Plot.legend({r: chart}); +} diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js new file mode 100644 index 0000000000..a3f5fa3883 --- /dev/null +++ b/test/plots/legend-swatches.js @@ -0,0 +1,6 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const plot = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"] }}); + return Plot.legend({color: plot}); +} diff --git a/yarn.lock b/yarn.lock index e7588e13da..127ffe92bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,6 +69,21 @@ resolved "https://registry.yarnpkg.com/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b" integrity sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950" + integrity sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA== + dependencies: + detect-libc "^1.0.3" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.1" + nopt "^5.0.0" + npmlog "^4.1.2" + rimraf "^3.0.2" + semver "^7.3.4" + tar "^6.1.0" + "@npmcli/arborist@^2.6.4": version "2.10.0" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-2.10.0.tgz#424c2d73a7ae59c960b0cc7f74fed043e4316c2c" @@ -700,6 +715,15 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== +canvas@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461" + integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + nan "^2.14.0" + simple-get "^3.0.3" + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -1242,6 +1266,13 @@ decimal.js@^10.3.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -1300,6 +1331,11 @@ depd@^1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + detect-port@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" @@ -2536,7 +2572,7 @@ magic-string@^0.25.5, magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.4" -make-dir@^3.0.2: +make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -2597,6 +2633,11 @@ mimic-response@^1.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -2740,6 +2781,11 @@ ms@2.1.3, ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nan@^2.14.0: + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + nanoid@3.1.25: version "3.1.25" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.25.tgz#09ca32747c0e543f0e1814b7d3793477f9c8e152" @@ -2760,6 +2806,11 @@ negotiator@^0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-gyp-build@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" @@ -3546,6 +3597,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f" integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + skypack@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/skypack/-/skypack-0.3.2.tgz#9df9fde1ed73ae6874d15111f0636e16f2cab1b9" From 20ae13112461b2301c1c69977c69f035c8ae8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 17:06:43 +0200 Subject: [PATCH 24/72] Plot.legend takes a scale and options --- src/legends.js | 12 +++--------- src/legends/color.js | 8 ++++---- src/legends/opacity.js | 6 +++--- src/legends/radius.js | 5 +++-- src/legends/ramp.js | 5 +++-- src/legends/swatches.js | 7 +++++-- test/output/legendColor.svg | 26 +++++++++++++------------- test/output/legendOpacity.svg | 2 +- test/output/legendRadius.svg | 22 +++++++++++----------- test/plots/legend-color.js | 4 ++-- test/plots/legend-opacity.js | 13 ++++--------- test/plots/legend-radius.js | 4 ++-- test/plots/legend-swatches.js | 4 ++-- 13 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/legends.js b/src/legends.js index 527c75717b..fdb02305d4 100644 --- a/src/legends.js +++ b/src/legends.js @@ -21,13 +21,7 @@ export function createLegends(descriptors, dimensions) { } export function legend({color, opacity, r, ...options}) { - if (color) return legendColor(plotOrScale(color, "color"), options); - if (r) return legendRadius(plotOrScale(r, "r"), options); - if (opacity) return legendOpacity(plotOrScale(opacity, "opacity"), options); -} - -function plotOrScale(p, scale) { - return (typeof p === "object" && "scale" in p && typeof p.scale === "function") - ? p.scale(scale) - : p; + if (color) return legendColor({...color, ...options}); + if (r) return legendRadius({...r, ...options}); + if (opacity) return legendOpacity({...opacity, ...r, ...options}); } diff --git a/src/legends/color.js b/src/legends/color.js index 5f07e36ccb..5a95409c91 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,8 +1,8 @@ import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(color, options) { - return color.type === "ordinal" || color.type === "categorical" - ? legendSwatches(color, options) - : legendRamp(color, options); +export function legendColor(scale) { + return scale.type === "ordinal" || scale.type === "categorical" + ? legendSwatches(scale) + : legendRamp(scale); } diff --git a/src/legends/opacity.js b/src/legends/opacity.js index e116d3e899..c710f97268 100644 --- a/src/legends/opacity.js +++ b/src/legends/opacity.js @@ -1,10 +1,10 @@ import {legendColor} from "./color.js"; -export function legendOpacity(opacity, options) { +export function legendOpacity(scale) { return legendColor({ - ...opacity, + ...scale, domain: [0, 1], interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` // scheme: "greys" - }, options); + }); } diff --git a/src/legends/radius.js b/src/legends/radius.js index 826d10d3ca..53fdab3243 100644 --- a/src/legends/radius.js +++ b/src/legends/radius.js @@ -4,14 +4,15 @@ import {text} from "../marks/text.js"; import {dot} from "../marks/dot.js"; import {scale} from "../scales.js"; -export function legendRadius(r, { +export function legendRadius({ label, ticks = 5, tickFormat = (d) => d, strokeWidth = 0.5, strokeDasharray = [5, 4], minStep = 8, - gap = 20 + gap = 20, + ...r }) { const s = scale(r); const r0 = s.range()[1]; diff --git a/src/legends/ramp.js b/src/legends/ramp.js index c53b13f24a..a8d6fd6696 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,7 +1,7 @@ import {scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; -export function legendRamp(color, { +export function legendRamp({ label, tickSize = 6, width = 240, @@ -12,7 +12,8 @@ export function legendRamp(color, { marginLeft = 0, ticks = width / 64, tickFormat, - tickValues + tickValues, + ...color } = {}) { color = scale(color); const svg = create("svg") diff --git a/src/legends/swatches.js b/src/legends/swatches.js index aa6cceef80..4bc571af16 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,6 +1,8 @@ import {scale} from "../scales.js"; import {create} from "d3"; +// TODO: once we inline, is this smart variable handling any +// better than inline styles? const styles = ` .plot-swatches { display: flex; @@ -50,7 +52,7 @@ const styles = ` } `; -export function legendSwatches(color, { +export function legendSwatches({ columns = null, format = x => x, swatchSize = 15, @@ -58,7 +60,8 @@ export function legendSwatches(color, { swatchHeight = swatchSize, marginLeft = 0, style = styles, - width + width, + ...color } = {}) { color = scale(color); const swatches = create("div") diff --git a/test/output/legendColor.svg b/test/output/legendColor.svg index d2db3ea7d4..5351fdcf31 100644 --- a/test/output/legendColor.svg +++ b/test/output/legendColor.svg @@ -1,38 +1,38 @@ - + - −1.0 + -15°C - −0.8 + -10°C - −0.6 + -5°C - −0.4 + 0°C - −0.2 + +5°C - 0.0 + +10°C - 0.2 + +15°C - 0.4 + +20°C - 0.6 + +25°C - 0.8 + +30°C - 1.0 - + +35°C + temperature \ No newline at end of file diff --git a/test/output/legendOpacity.svg b/test/output/legendOpacity.svg index b236d005bd..c079a3b781 100644 --- a/test/output/legendOpacity.svg +++ b/test/output/legendOpacity.svg @@ -18,6 +18,6 @@ 1.0 - + opaque \ No newline at end of file diff --git a/test/output/legendRadius.svg b/test/output/legendRadius.svg index 94607121ad..2cfa3c8b57 100644 --- a/test/output/legendRadius.svg +++ b/test/output/legendRadius.svg @@ -1,4 +1,4 @@ - + - - - - + + + + - - - - + + + + - 2015105 - + 2015105 + population \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 76b099ff48..111d3ff500 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -1,6 +1,6 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const plot = Plot.plot({color: {type: "diverging", domain: [-1, 1] }}); - return Plot.legend({color: plot, width: 500}); + const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); + return Plot.legend({color: chart.scale("color"), width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); } diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 2aaab20d10..50a6d88381 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -1,13 +1,8 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX({length: 100}, { - x: Math.random, - y: Math.random, - r: Math.random, - fill: Math.random, - fillOpacity: Math.random - }).plot({ r: { domain: [0, 20], label: "hello, radius" }}); - - return Plot.legend({opacity: chart}); + const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { + fillOpacity: "o" + }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); + return Plot.legend({opacity: chart.scale("opacity")}); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index 9a115e721e..f75146c5b7 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -5,7 +5,7 @@ export default async function() { x: d => d, r: d => d, fill: "red" - }).plot({ r: { domain: [0, 20], label: "test radius" }}); + }).plot({ r: { domain: [0, 20], label: "population" }}); - return Plot.legend({r: chart}); + return Plot.legend({r: chart.scale("r")}); } diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js index a3f5fa3883..b602318a3c 100644 --- a/test/plots/legend-swatches.js +++ b/test/plots/legend-swatches.js @@ -1,6 +1,6 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const plot = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"] }}); - return Plot.legend({color: plot}); + const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); + return Plot.legend({color: chart.scale("color")}); } From 47da1cba341e58842e7548508dfb40e4fbdeee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 27 Oct 2021 17:22:47 +0200 Subject: [PATCH 25/72] Remove Plot.legend, use chart.legend instead. --- README.md | 16 ++++++++-------- src/index.js | 1 - src/plot.js | 2 ++ test/plots/legend-color.js | 2 +- test/plots/legend-opacity.js | 2 +- test/plots/legend-radius.js | 2 +- test/plots/legend-swatches.js | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ea66d1b1f1..752dac71f1 100644 --- a/README.md +++ b/README.md @@ -228,11 +228,13 @@ The scale object is undefined if the associated plot has no scale with the given Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: -#### Plot.legend(*options*) +#### chart.legend(*name*[, *options*]) -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. +A suitable legend is returned for the chart’s scale name: *color*, *r*, or *opacity*. -The color swatches can be configured with the following options: +Categorical and ordinal color legends are rendered as swatches. + +The swatches can be configured with the following options: * *options*.**columns** - the number of swatches per row * *options*.**format** - a format function for the labels * *options*.**swatchSize** - the size of the swatch (if square) @@ -240,7 +242,7 @@ The color swatches can be configured with the following options: * *options*.**swatchHeight** - the swatches’ height * *options*.**marginLeft** - the legend’s left margin -The continuous color legends can be configured with the following options: +Continuous color legends are rendered as a ramp, and can be configured with the following options: * *options*.**label** - the scale’s label * *options*.**tickSize** - the tick size * *options*.**width** - the legend’s width @@ -253,11 +255,9 @@ The continuous color legends can be configured with the following options: * *options*.**tickFormat** - a format function for the legend’s ticks * *options*.**tickValues** - the legend’s tick values -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. - -If *options*.**r** is specified as a radius scale (or a chart), an radius legend is returned—rendered as circles on a common base. +An opacity legend is rendered as a grayscale color legend. The same options as above apply. -The radius legend can be configured with the following options: +The r legend is rendered as circles on a common base. It can be configured with the following options: * *options*.**label** - the scale’s label * *options*.**ticks** - the number of ticks (circles) * *options*.**tickFormat** - a format function for the ticks (TODO: format??) diff --git a/src/index.js b/src/index.js index 74bb524473..28d0645487 100644 --- a/src/index.js +++ b/src/index.js @@ -22,4 +22,3 @@ 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/plot.js b/src/plot.js index 9f43d66da6..9e9805c6c4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -2,6 +2,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; import {figureWrap} from "./figure.js"; +import { legend } from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; @@ -111,6 +112,7 @@ export function plot(options = {}) { // Wrap the plot in a figure with a caption, if desired. const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); + figure.legend = (type, options = {}) => legend({[type]: figure.scale(type), ...options}); return figure; } diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 111d3ff500..8d19e3ce5a 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -2,5 +2,5 @@ import * as Plot from "@observablehq/plot"; export default async function() { const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); - return Plot.legend({color: chart.scale("color"), width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); + return chart.legend("color", {width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); } diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 50a6d88381..3459f0de8a 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -4,5 +4,5 @@ export default async function() { const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { fillOpacity: "o" }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); - return Plot.legend({opacity: chart.scale("opacity")}); + return chart.legend("opacity"); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index f75146c5b7..c51ae54c23 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -7,5 +7,5 @@ export default async function() { fill: "red" }).plot({ r: { domain: [0, 20], label: "population" }}); - return Plot.legend({r: chart.scale("r")}); + return chart.legend("r"); } diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js index b602318a3c..1717d5a595 100644 --- a/test/plots/legend-swatches.js +++ b/test/plots/legend-swatches.js @@ -2,5 +2,5 @@ import * as Plot from "@observablehq/plot"; export default async function() { const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); - return Plot.legend({color: chart.scale("color")}); + return chart.legend("color"); } From b6f3d929da8ecc32637e4ec3cd1cd9a28e843866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Oct 2021 11:33:39 +0200 Subject: [PATCH 26/72] reinstate Plot.legend --- README.md | 4 ++++ src/index.js | 1 + src/legends.js | 2 +- src/legends/color.js | 8 +++++--- src/legends/opacity.js | 7 +------ src/legends/radius.js | 6 +++--- src/legends/ramp.js | 4 +--- src/legends/swatches.js | 4 +--- src/scales.js | 6 +----- test/output/legendDirect.svg | 38 +++++++++++++++++++++++++++++++++++ test/output/legendOpacity.svg | 32 ++++++++++++++++++++--------- test/output/legendRadius.svg | 16 +++++++-------- test/plots/index.js | 1 + test/plots/legend-direct.js | 13 ++++++++++++ test/plots/legend-opacity.js | 4 ++-- test/plots/legend-radius.js | 4 ++-- 16 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 test/output/legendDirect.svg create mode 100644 test/plots/legend-direct.js diff --git a/README.md b/README.md index 752dac71f1..4e213fa36e 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,10 @@ The r legend is rendered as circles on a common base. It can be configured with * *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8 * *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels. +#### Plot.legend({*name*: *scale*, ...*options*}) + +Builds a legend from a scale description object, passing the options described in the previous section. The name can be one of *color*, *opacity*, *r*, *x*, *y*, *fx*, *fy*. + ### Position options The position scales (*x*, *y*, *fx*, and *fy*) support additional options: 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 index fdb02305d4..99623c615f 100644 --- a/src/legends.js +++ b/src/legends.js @@ -23,5 +23,5 @@ export function createLegends(descriptors, dimensions) { export function legend({color, opacity, r, ...options}) { if (color) return legendColor({...color, ...options}); if (r) return legendRadius({...r, ...options}); - if (opacity) return legendOpacity({...opacity, ...r, ...options}); + if (opacity) return legendOpacity({...opacity, ...options}); } diff --git a/src/legends/color.js b/src/legends/color.js index 5a95409c91..e4ff70e17f 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,8 +1,10 @@ +import {Scale} from "../scales.js"; import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(scale) { +export function legendColor(options) { + const scale = Scale("color", undefined, options); return scale.type === "ordinal" || scale.type === "categorical" - ? legendSwatches(scale) - : legendRamp(scale); + ? legendSwatches({...scale, ...options}) + : legendRamp({...scale, ...options}); } diff --git a/src/legends/opacity.js b/src/legends/opacity.js index c710f97268..02de10910f 100644 --- a/src/legends/opacity.js +++ b/src/legends/opacity.js @@ -1,10 +1,5 @@ import {legendColor} from "./color.js"; export function legendOpacity(scale) { - return legendColor({ - ...scale, - domain: [0, 1], - interpolate: t => `rgb(${(1-t)*256}, ${(1-t)*256}, ${(1-t)*256})` - // scheme: "greys" - }); + return legendColor({...scale, interpolate: t => `rgba(0,0,0,${t})`}); } diff --git a/src/legends/radius.js b/src/legends/radius.js index 53fdab3243..52e104e9cd 100644 --- a/src/legends/radius.js +++ b/src/legends/radius.js @@ -1,8 +1,8 @@ +import {Scale} from "../scales.js"; import {plot} from "../plot.js"; import {link} from "../marks/link.js"; import {text} from "../marks/text.js"; import {dot} from "../marks/dot.js"; -import {scale} from "../scales.js"; export function legendRadius({ label, @@ -12,9 +12,9 @@ export function legendRadius({ strokeDasharray = [5, 4], minStep = 8, gap = 20, - ...r + ...options }) { - const s = scale(r); + const s = Scale("r", undefined, options).scale; const r0 = s.range()[1]; const shiftY = label ? 10 : 0; diff --git a/src/legends/ramp.js b/src/legends/ramp.js index a8d6fd6696..1d0d481f84 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,4 +1,3 @@ -import {scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; export function legendRamp({ @@ -13,9 +12,8 @@ export function legendRamp({ ticks = width / 64, tickFormat, tickValues, - ...color + scale: color } = {}) { - color = scale(color); const svg = create("svg") .attr("width", width) .attr("height", height) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 4bc571af16..663dc2e3f3 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,4 +1,3 @@ -import {scale} from "../scales.js"; import {create} from "d3"; // TODO: once we inline, is this smart variable handling any @@ -61,9 +60,8 @@ export function legendSwatches({ marginLeft = 0, style = styles, width, - ...color + scale: color } = {}) { - color = scale(color); const swatches = create("div") .classed("plot-swatches", true) .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ diff --git a/src/scales.js b/src/scales.js index 8d636b6547..476c5e89d4 100644 --- a/src/scales.js +++ b/src/scales.js @@ -60,10 +60,6 @@ export function Scales(channels, { return scales; } -export function scale(options) { - return Scale(options.key, undefined, options).scale; -} - // Mutates scale.range! export function autoScaleRange({x, y, fx, fy}, dimensions) { if (fx) autoScaleRangeX(fx, dimensions); @@ -122,7 +118,7 @@ function piecewiseRange(scale) { return Array.from({length}, (_, i) => start + i / (length - 1) * (end - start)); } -function Scale(key, channels = [], options = {}) { +export function Scale(key, channels = [], options = {}) { const type = inferScaleType(key, channels, options); options.type = type; // Mutates input! diff --git a/test/output/legendDirect.svg b/test/output/legendDirect.svg new file mode 100644 index 0000000000..5351fdcf31 --- /dev/null +++ b/test/output/legendDirect.svg @@ -0,0 +1,38 @@ + + + + + -15°C + + + -10°C + + + -5°C + + + 0°C + + + +5°C + + + +10°C + + + +15°C + + + +20°C + + + +25°C + + + +30°C + + + +35°C + temperature + + \ No newline at end of file diff --git a/test/output/legendOpacity.svg b/test/output/legendOpacity.svg index c079a3b781..24a581df86 100644 --- a/test/output/legendOpacity.svg +++ b/test/output/legendOpacity.svg @@ -1,23 +1,35 @@ - + - 0.0 + 1 - - 0.2 + + 2 - - 0.4 + + 3 - 0.6 + - - 0.8 + + + + + + + + + + + + + + - 1.0 + 10 opaque \ No newline at end of file diff --git a/test/output/legendRadius.svg b/test/output/legendRadius.svg index 2cfa3c8b57..88a463bbba 100644 --- a/test/output/legendRadius.svg +++ b/test/output/legendRadius.svg @@ -14,16 +14,16 @@ - - - + + + - - - + + + - 2015105 - population + 10642 + population (millions) \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index ecaf76da2e..c6b998d49d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,6 +53,7 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; +export {default as legendDirect} from "./legend-direct.js"; export {default as legendOpacity} from "./legend-opacity.js"; export {default as legendRadius} from "./legend-radius.js"; export {default as legendSwatches} from "./legend-swatches.js"; diff --git a/test/plots/legend-direct.js b/test/plots/legend-direct.js new file mode 100644 index 0000000000..c138ced4c9 --- /dev/null +++ b/test/plots/legend-direct.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + return Plot.legend({ + color: { + scheme: "burd", + domain: [-15, 35], + label: "temperature" + }, + width: 500, + tickFormat: d => `${d > 0 ? "+" : ""}${d}°C` + }); +} diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js index 3459f0de8a..da83d7eb49 100644 --- a/test/plots/legend-opacity.js +++ b/test/plots/legend-opacity.js @@ -1,8 +1,8 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX([{o: 0.1}, {o: 0.5}], { + const chart = Plot.dotX([{o: 1}, {o: 10}], { fillOpacity: "o" - }).plot({ opacity: { domain: [0, 20], label: "opaque" }}); + }).plot({ opacity: {type: "log", label: "opaque", legend: true}}); return chart.legend("opacity"); } diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js index c51ae54c23..30c670c5f1 100644 --- a/test/plots/legend-radius.js +++ b/test/plots/legend-radius.js @@ -1,11 +1,11 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.dotX([0, 0.1, 0.2, 0.8, 0.9, 1], { + const chart = Plot.dotX([0, 1e6, 2e6, 8e6, 9e6, 1e7], { x: d => d, r: d => d, fill: "red" - }).plot({ r: { domain: [0, 20], label: "population" }}); + }).plot({ r: {range: [0, 30], label: "population (millions)", transform: d => d * 1e-6}}); return chart.legend("r"); } From 53c8108b538a038b3cd1a0c31f21565e280174e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Oct 2021 19:15:10 +0200 Subject: [PATCH 27/72] unused code --- src/legends.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/legends.js b/src/legends.js index 99623c615f..c51921546e 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,25 +1,7 @@ -import {registry} from "./scales/index.js"; import {legendColor} from "./legends/color.js"; import {legendOpacity} from "./legends/opacity.js"; import {legendRadius} from "./legends/radius.js"; -export function createLegends(descriptors, dimensions) { - const legends = []; - for (const [key] of registry) { - const scale = descriptors(key); - if (scale === undefined) continue; - let {legend, ...options} = scale; - if (key === "color" && legend === true) legend = legendColor; - if (key === "opacity" && legend === true) legend = legendOpacity; - if (key === "r" && legend === true) legend = legendRadius; - if (typeof legend === "function") { - const l = legend(options, dimensions); - if (l instanceof Node) legends.push(l); - } - } - return legends; -} - export function legend({color, opacity, r, ...options}) { if (color) return legendColor({...color, ...options}); if (r) return legendRadius({...r, ...options}); From e285768c30179262403068214616740a26b9843b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:31:31 +0200 Subject: [PATCH 28/72] allow legend: "ramp" as an option --- README.md | 2 +- src/legends/color.js | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4e213fa36e..fc807eb089 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a A suitable legend is returned for the chart’s scale name: *color*, *r*, or *opacity*. -Categorical and ordinal color legends are rendered as swatches. +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*.**columns** - the number of swatches per row diff --git a/src/legends/color.js b/src/legends/color.js index e4ff70e17f..0fbe274cfc 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -2,9 +2,15 @@ import {Scale} from "../scales.js"; import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(options) { +export function legendColor({legend, ...options}) { const scale = Scale("color", undefined, options); - return scale.type === "ordinal" || scale.type === "categorical" - ? legendSwatches({...scale, ...options}) - : legendRamp({...scale, ...options}); + if (legend === undefined) legend = scale.type === "ordinal" || scale.type === "categorical" ? "swatches" : "ramp"; + switch (legend) { + case "swatches": + return legendSwatches({...scale, ...options}); + case "ramp": + return legendRamp({...scale, ...options}); + default: + throw new Error(`unknown legend type ${legend}`); + } } From ff9669cb5847323348c91c3ff98668c3cec2b05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:53:21 +0200 Subject: [PATCH 29/72] snapshot test for a lot of color legends --- test/output/legendColor.html | 805 +++++++++++++++++++++++++++++++++++ test/output/legendColor.svg | 38 -- test/plots/legend-color.js | 202 ++++++++- 3 files changed, 1005 insertions(+), 40 deletions(-) create mode 100644 test/output/legendColor.html delete mode 100644 test/output/legendColor.svg diff --git a/test/output/legendColor.html b/test/output/legendColor.html new file mode 100644 index 0000000000..28ae339820 --- /dev/null +++ b/test/output/legendColor.html @@ -0,0 +1,805 @@ +
+
+ ABCDEFGHIJ +
+ + + + + + + + + + + + + + + A + + + B + + + C + + + D + + + E + + + F + + + G + + + H + + + I + + + J + + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + scale label + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + legend label + + + + I feel blue + +
+ DCBA +
+
+ ABCD +
+ + + + + + + + + + + + 200 + + + 800 + + + 1,800 + + + 3,201 + + + 5,001 + + + 7,201 + quantiles! + + + + + + + + + + + + + + + 2.0 + + + 3.0 + + + 4.0 + + + 5.0 + + + 6.0 + + + 7.0 + + + 8.0 + thresholds! + + + + + + 0 + + + 20 + + + 40 + + + 60 + + + 80 + + + 100 + Temperature (°F) + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Speed (kts) + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + + + + + + + + + + + + + 10 + + + 20 + + + 30 + + + 40 + + + 50 + + + + + + + + + + + + + + + 100 + Energy (joules) + + + + + + −100 + + + −50 + + + 0 + + + 50 + + + 100 + Temperature (°C) + + + + + + + + + + + + + + + + + 75 + + + 85 + + + 91 + + + 96 + + + 101 + + + 106 + + + 111 + + + 118 + + + 127 + Height (cm) + + + + + + + + + + + + + + + + 2.5 + + + 3.1 + + + 3.5 + + + 3.9 + + + 6 + + + 7 + + + 8 + + + 9.5 + Unemployment rate (%) + + +
+ <1010-1920-2930-3940-4950-5960-6970-79≥80 +
+ + + + + + + + + + + + + + <10 + + + 10-19 + + + 20-29 + + + 30-39 + + + 40-49 + + + 50-59 + + + 60-69 + + + 70-79 + + + ≥80 + Age (years) + + +
+ blueberriesorangesapples +
+
+ +
+
+
+
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/legendColor.svg b/test/output/legendColor.svg deleted file mode 100644 index 5351fdcf31..0000000000 --- a/test/output/legendColor.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - -15°C - - - -10°C - - - -5°C - - - 0°C - - - +5°C - - - +10°C - - - +15°C - - - +20°C - - - +25°C - - - +30°C - - - +35°C - temperature - - \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 8d19e3ce5a..1bed43ced3 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -1,6 +1,204 @@ +import * as d3 from "d3"; import * as Plot from "@observablehq/plot"; export default async function() { - const chart = Plot.plot({color: {scheme: "burd", domain: [-15, 35], label: "temperature" }}); - return chart.legend("color", {width: 500, tickFormat: d => `${d > 0 ? "+" : ""}${d}°C`}); + const div = document.createElement("div"); + + const ordinal = Plot.dot("ABCDEFGHIJ", {x: 0, fill: d => d}).plot(); + + for (const l of [ + ordinal.legend("color"), + + ordinal.legend("color", {legend: "ramp"}), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot().legend("color"), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ + color: {label: "scale label"} + }).legend("color"), + + Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ + color: {label: "scale label"} + }).legend("color", {label: "legend label"}), + + Plot.legend({ + color: { + width: 400, + type: "sqrt", + scheme: "blues", + range: [0.25, 1], + label: "I feel blue", + marginLeft: 150, + marginRight: 50 + } + }), + + Plot.legend({ color: { domain: "DCBA", scheme: "rainbow" } }), + + Plot.legend({ color: { domain: "DCBA", reverse: true } }), + + Plot.legend({ + color: Plot.plot({ + marks: [ + Plot.dotX(d3.range(100), { + x: (i) => i, + y: (i) => i ** 2, + fill: (i) => i ** 2 + }) + ], + color: { type: "quantile", scheme: "inferno", quantiles: 7 } + }).scale("color"), + width: 300, + label: "quantiles!", + tickFormat: ",d" + }), + + Plot.legend({ + color: { + type: "threshold", + domain: d3.ticks(2, 8, 5), + scheme: "viridis" + }, + width: 300, + label: "thresholds!", + tickFormat: (d) => d.toFixed(1) + }), + + Plot.legend({ + color: { scheme: "viridis", domain: [0, 100], label: "Temperature (°F)" } + }), + + Plot.legend({ + color: { scheme: "Turbo", type: "sqrt", domain: [0, 1], label: "Speed (kts)" } + }), + + Plot.legend({ + color: { + type: "diverging", + domain: [-0.1, 0.1], + scheme: "PiYG", + label: "Daily change", + tickFormat: "+%" + } + }), + + Plot.legend({ + color: { + type: "diverging-sqrt", + domain: [-0.1, 0.1], + scheme: "RdBu", + label: "Daily change", + tickFormat: "+%" + } + }), + + Plot.legend({ + color: { + type: "log", + domain: [1, 100], + scheme: "Blues", + label: "Energy (joules)", + ticks: 10, + width: 380 + } + }), + + Plot.legend({ + color: { + type: "sqrt", + domain: [-100, 0, 100], + range: ["blue", "white", "red"], + label: "Temperature (°C)", + interpolate: "rgb" + } + }), + + Plot.legend({ + color: { + type: "quantile", + domain: d3.range(1000).map(d3.randomNormal(100, 20)), + scheme: "Spectral", + label: "Height (cm)", + tickFormat: ".0f", + width: 400, + quantiles: 10 + } + }), + + Plot.legend({ + color: { + type: "threshold", + domain: [2.5, 3.1, 3.5, 3.9, 6, 7, 8, 9.5], + scheme: "RdBu", + label: "Unemployment rate (%)", + tickSize: 0 + } + }), + + Plot.legend({ + color: { + domain: [ + "<10", + "10-19", + "20-29", + "30-39", + "40-49", + "50-59", + "60-69", + "70-79", + "≥80" + ], + scheme: "Spectral", + label: "Age (years)", + tickSize: 0 + } + }), + + Plot.legend({ + color: { + domain: [ + "<10", + "10-19", + "20-29", + "30-39", + "40-49", + "50-59", + "60-69", + "70-79", + "≥80" + ], + scheme: "Spectral", + label: "Age (years)", + tickSize: 0, + legend: "ramp", + width: 400 + } + }), + + Plot.legend({ + color: { domain: ["blueberries", "oranges", "apples"], scheme: "category10" } + }), + + Plot.legend({ + color: { + domain: [ + "Wholesale and Retail Trade", + "Manufacturing", + "Leisure and hospitality", + "Business services", + "Construction", + "Education and Health", + "Government", + "Finance", + "Self-employed", + "Other" + ], + columns: "180px", // responsive! + width: 960 + } + }) + + ]) div.appendChild(l); + + return div; } From f7d33b3fcc8737c10d7da3fe61b55fc6423bac2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 14:59:49 +0200 Subject: [PATCH 30/72] no random in unit tests --- test/output/legendColor.html | 14 +++++++------- test/plots/legend-color.js | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 28ae339820..f34f18e31f 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -503,25 +503,25 @@ - 75 + 73 - 85 + 82 - 91 + 90 - 96 + 94 - 101 + 100 - 106 + 105 - 111 + 112 118 diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 1bed43ced3..3ea09785ea 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -116,7 +116,7 @@ export default async function() { Plot.legend({ color: { type: "quantile", - domain: d3.range(1000).map(d3.randomNormal(100, 20)), + domain: d3.range(1000).map(d3.randomNormal.source(d3.randomLcg(42))(100, 20)), scheme: "Spectral", label: "Height (cm)", tickFormat: ".0f", From 7eb7d0a2fc9c9bb8c84916b672684ed22279826a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 15:04:08 +0200 Subject: [PATCH 31/72] remove redundant color tests --- test/output/legendDirect.svg | 38 ------------------------- test/output/legendSwatches.html | 50 --------------------------------- test/plots/index.js | 2 -- test/plots/legend-direct.js | 13 --------- test/plots/legend-swatches.js | 6 ---- 5 files changed, 109 deletions(-) delete mode 100644 test/output/legendDirect.svg delete mode 100644 test/output/legendSwatches.html delete mode 100644 test/plots/legend-direct.js delete mode 100644 test/plots/legend-swatches.js diff --git a/test/output/legendDirect.svg b/test/output/legendDirect.svg deleted file mode 100644 index 5351fdcf31..0000000000 --- a/test/output/legendDirect.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - -15°C - - - -10°C - - - -5°C - - - 0°C - - - +5°C - - - +10°C - - - +15°C - - - +20°C - - - +25°C - - - +30°C - - - +35°C - temperature - - \ No newline at end of file diff --git a/test/output/legendSwatches.html b/test/output/legendSwatches.html deleted file mode 100644 index 97bcefdf56..0000000000 --- a/test/output/legendSwatches.html +++ /dev/null @@ -1,50 +0,0 @@ -
- ABC -
\ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index c6b998d49d..8fd4b02ddd 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,10 +53,8 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; -export {default as legendDirect} from "./legend-direct.js"; export {default as legendOpacity} from "./legend-opacity.js"; export {default as legendRadius} from "./legend-radius.js"; -export {default as legendSwatches} from "./legend-swatches.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-direct.js b/test/plots/legend-direct.js deleted file mode 100644 index c138ced4c9..0000000000 --- a/test/plots/legend-direct.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - return Plot.legend({ - color: { - scheme: "burd", - domain: [-15, 35], - label: "temperature" - }, - width: 500, - tickFormat: d => `${d > 0 ? "+" : ""}${d}°C` - }); -} diff --git a/test/plots/legend-swatches.js b/test/plots/legend-swatches.js deleted file mode 100644 index 1717d5a595..0000000000 --- a/test/plots/legend-swatches.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.plot({color: {type: "categorical", domain: ["A", "B", "C"], label: "category" }}); - return chart.legend("color"); -} From 063030495d8d9b4ad7021ef3b9ca1e7ffe44fe10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 15:22:59 +0200 Subject: [PATCH 32/72] accept a label on swatches --- src/legends/swatches.js | 14 ++++- test/output/legendColor.html | 101 ++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 663dc2e3f3..1f4caddf22 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -54,6 +54,7 @@ const styles = ` export function legendSwatches({ columns = null, format = x => x, + label, swatchSize = 15, swatchWidth = swatchSize, swatchHeight = swatchSize, @@ -92,7 +93,18 @@ export function legendSwatches({ .style("--color", color) .text(format); } - return swatches.node(); + + return label == null + ? swatches.node() + : create("div") + .call(div => div.append("div") + .style("font-weight", "bold") + .style("font-family", "sans-serif") + .style("font-size", "10px") + .style("margin", "5px 0 -5px 0") + .text(label)) + .call(div => div.append(() => swatches.node())) + .node(); } function entity(character) { diff --git a/test/output/legendColor.html b/test/output/legendColor.html index f34f18e31f..60bbf6bf9a 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -569,55 +569,58 @@
Unemployment rate (%)
-
- <1010-1920-2930-3940-4950-5960-6970-79≥80 +
+
Age (years)
+
+ <1010-1920-2930-3940-4950-5960-6970-79≥80 +
From c5be06d92856e6df858cd4bba5bd4010f28c13ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:02:07 +0200 Subject: [PATCH 33/72] className for legendSwatches --- src/legends/swatches.js | 19 ++++++---- test/output/legendColor.html | 72 ++++++++++++++++++------------------ test/plots/legend-color.js | 18 +++++---- 3 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 1f4caddf22..5e37b4f9c9 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,9 +1,10 @@ import {create} from "d3"; +import {maybeClassName} from "../style.js"; // TODO: once we inline, is this smart variable handling any // better than inline styles? -const styles = ` -.plot-swatches { +const styles = uid => ` +.${uid} { display: flex; align-items: center; margin-left: var(--marginLeft); @@ -12,25 +13,25 @@ const styles = ` margin-bottom: 0.5em; } -.plot-swatches > div { +.${uid} > div { width: 100%; } -.plot-swatches .swatch-item { +.${uid} .swatch-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } -.plot-swatches .swatch-label { +.${uid} .swatch-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } -.plot-swatches .swatch-block { +.${uid} .swatch-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; @@ -59,12 +60,14 @@ export function legendSwatches({ swatchWidth = swatchSize, swatchHeight = swatchSize, marginLeft = 0, - style = styles, + className, + uid = maybeClassName(className), + style = styles(uid), width, scale: color } = {}) { const swatches = create("div") - .classed("plot-swatches", true) + .classed(uid, true) .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ width === undefined ? "" : ` width: ${width}px;` }`); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 60bbf6bf9a..8de60e0cf4 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -1,7 +1,7 @@
-
+
DCBA
-
+
blueberriesorangesapples
-
+
- - - - - - - - - - - - - 10642 - population (millions) - \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 8fd4b02ddd..c867726a6d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -53,8 +53,6 @@ export {default as industryUnemploymentShare} from "./industry-unemployment-shar export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; export {default as legendColor} from "./legend-color.js"; -export {default as legendOpacity} from "./legend-opacity.js"; -export {default as legendRadius} from "./legend-radius.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js deleted file mode 100644 index da83d7eb49..0000000000 --- a/test/plots/legend-opacity.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.dotX([{o: 1}, {o: 10}], { - fillOpacity: "o" - }).plot({ opacity: {type: "log", label: "opaque", legend: true}}); - return chart.legend("opacity"); -} diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js deleted file mode 100644 index 30c670c5f1..0000000000 --- a/test/plots/legend-radius.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as Plot from "@observablehq/plot"; - -export default async function() { - const chart = Plot.dotX([0, 1e6, 2e6, 8e6, 9e6, 1e7], { - x: d => d, - r: d => d, - fill: "red" - }).plot({ r: {range: [0, 30], label: "population (millions)", transform: d => d * 1e-6}}); - - return chart.legend("r"); -} From d744483183e7f37d67dd7a07fcbcc5ac4d4fba45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:16:38 +0200 Subject: [PATCH 35/72] more scope --- src/legends/swatches.js | 4 ++-- test/output/legendColor.html | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 5e37b4f9c9..0510ed176f 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -37,13 +37,13 @@ const styles = uid => ` margin: 0 0.5em 0 0; } -.plot-swatch { +.${uid} .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } -.plot-swatch::before { +.${uid} .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 8de60e0cf4..83ce104a60 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -34,13 +34,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches1 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches1 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -198,13 +198,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches2 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches2 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -248,13 +248,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches3 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches3 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -606,13 +606,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches4 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches4 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -698,13 +698,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches5 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches5 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -748,13 +748,13 @@ margin: 0 0.5em 0 0; } - .plot-swatch { + .swatches6 .plot-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .plot-swatch::before { + .swatches6 .plot-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); From a71a2c001114a41dda65d517af7aec9b21ecb2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 16:26:18 +0200 Subject: [PATCH 36/72] only color is available in this branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 008ab18147..ed3caab5f3 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ The scale object is undefined if the associated plot has no scale with the given ### Legends -Given a chart’s *color*, *opacity* or *r* (radius) scale, Plot can generate a legend: +Given a chart’s *color* scale, Plot can generate a legend: #### chart.legend(*name*[, *options*]) From 8cef47222f09bb8c090ce4c3ce1b620aa1401e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 29 Oct 2021 17:36:23 +0200 Subject: [PATCH 37/72] do not expose any class --- src/legends/swatches.js | 30 +++--- test/output/legendColor.html | 174 ++++++++++++++++++++++------------- 2 files changed, 125 insertions(+), 79 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 0510ed176f..9538ace4e5 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -17,39 +17,46 @@ const styles = uid => ` width: 100%; } -.${uid} .swatch-item { +.${uid}-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } -.${uid} .swatch-label { +.${uid}-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } -.${uid} .swatch-block { +.${uid}-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } -.${uid} .plot-swatch { +.${uid}-swatch { display: inline-flex; align-items: center; margin-right: 1em; } -.${uid} .plot-swatch::before { +.${uid}-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } + +.${uid}-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; +} `; export function legendSwatches({ @@ -77,13 +84,13 @@ export function legendSwatches({ const elems = swatches.append("div") .style("columns", columns); for (const value of color.domain()) { - const d = elems.append("div").classed("swatch-item", true); + const d = elems.append("div").classed(`${uid}-item`, true); d.append("div") - .classed("swatch-block", true) + .classed(`${uid}-block`, true) .style("background", color(value)); const label = format(value); d.append("div") - .classed("swatch-label", true) + .classed(`${uid}-label`, true) .text(label) .attr("title", label.replace(/["&]/g, entity)); } @@ -92,7 +99,7 @@ export function legendSwatches({ .selectAll() .data(color.domain()) .join("span") - .classed("plot-swatch", true) + .classed(`${uid}-swatch`, true) .style("--color", color) .text(format); } @@ -101,10 +108,7 @@ export function legendSwatches({ ? swatches.node() : create("div") .call(div => div.append("div") - .style("font-weight", "bold") - .style("font-family", "sans-serif") - .style("font-size", "10px") - .style("margin", "5px 0 -5px 0") + .classed(`${uid}-title`, true) .text(label)) .call(div => div.append(() => swatches.node())) .node(); diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 83ce104a60..51d25c9ba0 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -14,40 +14,47 @@ width: 100%; } - .swatches1 .swatch-item { + .swatches1-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches1 .swatch-label { + .swatches1-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches1 .swatch-block { + .swatches1-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches1 .plot-swatch { + .swatches1-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches1 .plot-swatch::before { + .swatches1-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - ABCDEFGHIJ + + .swatches1-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + ABCDEFGHIJ
@@ -178,40 +185,47 @@ width: 100%; } - .swatches2 .swatch-item { + .swatches2-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches2 .swatch-label { + .swatches2-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches2 .swatch-block { + .swatches2-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches2 .plot-swatch { + .swatches2-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches2 .plot-swatch::before { + .swatches2-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - DCBA + + .swatches2-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + DCBA
ABCD + + .swatches3-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + ABCD
@@ -570,7 +591,7 @@
-
Age (years)
+
Age (years)
<1010-1920-2930-3940-4950-5960-6970-79≥80 + + .swatches4-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + <1010-1920-2930-3940-4950-5960-6970-79≥80
@@ -678,40 +706,47 @@ width: 100%; } - .swatches5 .swatch-item { + .swatches5-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } - .swatches5 .swatch-label { + .swatches5-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } - .swatches5 .swatch-block { + .swatches5-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } - .swatches5 .plot-swatch { + .swatches5-swatch { display: inline-flex; align-items: center; margin-right: 1em; } - .swatches5 .plot-swatch::before { + .swatches5-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); margin-right: 0.5em; background: var(--color); } - blueberriesorangesapples + + .swatches5-title { + font-weight: bold; + font-family: sans-serif; + font-size: 10px; + margin: 5px 0 -5px 0; + } + blueberriesorangesapples
-
-
-
Wholesale and Retail Trade
+
+
+
Wholesale and Retail Trade
-
-
-
Manufacturing
+
+
+
Manufacturing
-
-
-
Leisure and hospitality
+
+
+
Leisure and hospitality
-
-
-
Business services
+
+
+
Business services
-
-
-
Construction
+
+
+
Construction
-
-
-
Education and Health
+
+
+
Education and Health
-
-
-
Government
+
+
+
Government
-
-
-
Finance
+
+
+
Finance
-
-
-
Self-employed
+
+
+
Self-employed
-
-
-
Other
+
+
+
Other
From 12e0e47cc53bad37c8d850bd9428f2a91052816c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:51:59 -0800 Subject: [PATCH 38/72] error on unknown legend type --- src/legends.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/legends.js b/src/legends.js index 43994734a1..f36174c75b 100644 --- a/src/legends.js +++ b/src/legends.js @@ -2,4 +2,5 @@ import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { if (color) return legendColor({...color, ...options}); + throw new Error(`unsupported legend type`); } From e7000c9d9f1e4c633ded0357228f85a6c7c55082 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:52:28 -0800 Subject: [PATCH 39/72] categorical is normalized to ordinal --- src/legends/color.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends/color.js b/src/legends/color.js index 0fbe274cfc..840630b956 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -4,7 +4,7 @@ import {legendSwatches} from "./swatches.js"; export function legendColor({legend, ...options}) { const scale = Scale("color", undefined, options); - if (legend === undefined) legend = scale.type === "ordinal" || scale.type === "categorical" ? "swatches" : "ramp"; + if (legend === undefined) legend = scale.type === "ordinal" ? "swatches" : "ramp"; switch (legend) { case "swatches": return legendSwatches({...scale, ...options}); From 7791d3e76ccf1f8530021b86b1b5fdb69704225f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:52:37 -0800 Subject: [PATCH 40/72] show unknown legend type in error --- src/legends/color.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends/color.js b/src/legends/color.js index 840630b956..3ba6d40332 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -11,6 +11,6 @@ export function legendColor({legend, ...options}) { case "ramp": return legendRamp({...scale, ...options}); default: - throw new Error(`unknown legend type ${legend}`); + throw new Error(`unknown color legend type: ${legend}`); } } From a92d7dab09e4eb113faf45e9a08a625681cb0bcf Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:53:03 -0800 Subject: [PATCH 41/72] prEtTieR --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 9e9805c6c4..8a2c893458 100644 --- a/src/plot.js +++ b/src/plot.js @@ -2,7 +2,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; import {figureWrap} from "./figure.js"; -import { legend } from "./legends.js"; +import {legend} from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {filterStyles, maybeClassName, offset} from "./style.js"; From 3ecdcb4c3978f7789d01e44f8ae914548ddbf930 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:53:35 -0800 Subject: [PATCH 42/72] prioritize type; avoid unnecessary default --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 8a2c893458..3145348ca4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -112,7 +112,7 @@ export function plot(options = {}) { // Wrap the plot in a figure with a caption, if desired. const figure = figureWrap(svg, dimensions, caption); figure.scale = exposeScales(scaleDescriptors); - figure.legend = (type, options = {}) => legend({[type]: figure.scale(type), ...options}); + figure.legend = (type, options) => legend({...options, [type]: figure.scale(type)}); return figure; } From 72e5278b858d376c4cab05464e7c673094253826 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:54:47 -0800 Subject: [PATCH 43/72] non-nullish, not truthy --- src/legends.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends.js b/src/legends.js index f36174c75b..b86a7780a0 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,6 +1,6 @@ import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { - if (color) return legendColor({...color, ...options}); + if (color != null) return legendColor({...color, ...options}); throw new Error(`unsupported legend type`); } From 6bafb9240ee781fe44148c034bcf1f5957f24cba Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 8 Nov 2021 08:57:10 -0800 Subject: [PATCH 44/72] =?UTF-8?q?div.append(=E2=80=A6nodes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/plots/legend-color.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 1c39b6f868..cacd7aa63d 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -6,7 +6,7 @@ export default async function() { const ordinal = Plot.dot("ABCDEFGHIJ", {x: 0, fill: d => d}).plot(); - for (const l of [ + div.append( ordinal.legend("color", {className: "swatches1"}), ordinal.legend("color", {legend: "ramp"}), @@ -124,7 +124,7 @@ export default async function() { quantiles: 10 } }), - + Plot.legend({ color: { type: "threshold", @@ -200,7 +200,7 @@ export default async function() { } }) - ]) div.appendChild(l); + ); return div; } From 9630852d168e5b325537f783d60a40348911683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 12:59:24 +0100 Subject: [PATCH 45/72] less duck typing --- src/legends/color.js | 4 ++-- src/legends/ramp.js | 12 ++++-------- src/legends/swatches.js | 5 ++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/legends/color.js b/src/legends/color.js index 3ba6d40332..fb66431e22 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -7,9 +7,9 @@ export function legendColor({legend, ...options}) { if (legend === undefined) legend = scale.type === "ordinal" ? "swatches" : "ramp"; switch (legend) { case "swatches": - return legendSwatches({...scale, ...options}); + return legendSwatches(scale.scale, options); case "ramp": - return legendRamp({...scale, ...options}); + return legendRamp(scale.scale, options); default: throw new Error(`unknown color legend type: ${legend}`); } diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 1d0d481f84..1b9b95a4e2 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,6 +1,6 @@ import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; -export function legendRamp({ +export function legendRamp(color, { label, tickSize = 6, width = 240, @@ -12,7 +12,7 @@ export function legendRamp({ ticks = width / 64, tickFormat, tickValues, - scale: color + type } = {}) { const svg = create("svg") .attr("width", width) @@ -73,12 +73,8 @@ export function legendRamp({ } // Threshold - else if (color.invertExtent) { - const thresholds - = color.thresholds ? color.thresholds() // scaleQuantize - : color.quantiles ? color.quantiles() // scaleQuantile - : color.domain(); // scaleThreshold - + else if (type === "threshold" || type === "quantile") { + const thresholds = color.domain(); const thresholdFormat = tickFormat === undefined ? d => d : typeof tickFormat === "string" ? format(tickFormat) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 9538ace4e5..4dc3163da6 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -59,7 +59,7 @@ const styles = uid => ` } `; -export function legendSwatches({ +export function legendSwatches(color, { columns = null, format = x => x, label, @@ -70,8 +70,7 @@ export function legendSwatches({ className, uid = maybeClassName(className), style = styles(uid), - width, - scale: color + width } = {}) { const swatches = create("div") .classed(uid, true) From e85d7e1b306a03a43e3e547b2cfb80e53c3d8e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 12:59:32 +0100 Subject: [PATCH 46/72] remove entity filtering --- src/legends/swatches.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 4dc3163da6..62d736839a 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -91,7 +91,7 @@ export function legendSwatches(color, { d.append("div") .classed(`${uid}-label`, true) .text(label) - .attr("title", label.replace(/["&]/g, entity)); + .attr("title", label); } } else { swatches @@ -112,7 +112,3 @@ export function legendSwatches(color, { .call(div => div.append(() => swatches.node())) .node(); } - -function entity(character) { - return `&#${character.charCodeAt(0).toString()};`; -} From ad2283035499337899eabc381df11f18095e5eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 15:55:35 +0100 Subject: [PATCH 47/72] reduce duck-typing --- src/legends.js | 26 +++++++++++++++++++++++++- src/legends/color.js | 10 ++++------ src/legends/ramp.js | 33 ++++++++++++++++++--------------- src/legends/swatches.js | 8 ++++---- src/scales.js | 2 +- src/scales/diverging.js | 3 ++- test/plots/legend-color.js | 10 ++++++++++ 7 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/legends.js b/src/legends.js index b86a7780a0..7c7f5e3e2d 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,6 +1,30 @@ +import {exposeScale, Scale} from "./scales.js"; import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { - if (color != null) return legendColor({...color, ...options}); + if (color != null) { + return legendColor(exposeScale(Scale("color", undefined, color)), { +// ...color, + legend: color.legend, + label: color.label, + tickSize: color.tickSize, + width: color.width, + height: color.height, + marginTop: color.marginTop, + marginRight: color.marginRight, + marginBottom: color.marginBottom, + marginLeft: color.marginLeft, + ticks: color.ticks, + tickFormat: color.tickFormat, + tickValues: color.tickValues, + format: color.format, + className: color.className, + swatchSize: color.swatchSize, + swatchWidth: color.swatchWidth, + swatchHeight: color.swatchHeight, + columns: color.columns, + ...options + }); + } throw new Error(`unsupported legend type`); } diff --git a/src/legends/color.js b/src/legends/color.js index fb66431e22..d1770ffc4e 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,15 +1,13 @@ -import {Scale} from "../scales.js"; import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor({legend, ...options}) { - const scale = Scale("color", undefined, options); - if (legend === undefined) legend = scale.type === "ordinal" ? "swatches" : "ramp"; +export function legendColor(color, {legend, ...options}) { + if (legend === undefined) legend = color.type === "ordinal" ? "swatches" : "ramp"; switch (legend) { case "swatches": - return legendSwatches(scale.scale, options); + return legendSwatches(color, options); case "ramp": - return legendRamp(scale.scale, options); + return legendRamp(color, options); default: throw new Error(`unknown color legend type: ${legend}`); } diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 1b9b95a4e2..1315609f11 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,6 +1,7 @@ +import {Scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; -export function legendRamp(color, { +export function legendRamp(scale, { label, tickSize = 6, width = 240, @@ -11,9 +12,11 @@ export function legendRamp(color, { marginLeft = 0, ticks = width / 64, tickFormat, - tickValues, - type -} = {}) { + tickValues +}) { + let {type, apply, domain, pivot, range: scaleRange} = scale; + if (pivot !== undefined) domain = [domain[0], pivot, domain[1]]; // diverging scales + const color = Scale("color", undefined, scale).scale; const svg = create("svg") .attr("width", width) .attr("height", height) @@ -26,16 +29,16 @@ export function legendRamp(color, { // Continuous if (color.interpolate) { - const n = Math.min(color.domain().length, color.range().length); + const n = Math.min(domain.length, scaleRange.length); x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n)); let color2 = color.copy().domain(quantize(interpolate(0, 1), n)); // special case for log scales - if (color.base) { + if (type === "log") { const p = scaleLinear( - quantize(interpolate(0, 1), color.domain().length), - color.domain().map(d => Math.log(d)) + quantize(interpolate(0, 1), domain.length), + domain.map(d => Math.log(d)) ); - color2 = t => color(Math.exp(p(t))); + color2 = t => apply(Math.exp(p(t))); } svg.append("image") .attr("x", marginLeft) @@ -64,7 +67,7 @@ export function legendRamp(color, { if (!x.ticks) { if (tickValues === undefined) { const n = Math.round(ticks + 1); - tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1))); + tickValues = range(n).map(i => quantile(domain, i / (n - 1))); } if (typeof tickFormat !== "function") { tickFormat = format(tickFormat === undefined ? ",f" : tickFormat); @@ -74,19 +77,19 @@ export function legendRamp(color, { // Threshold else if (type === "threshold" || type === "quantile") { - const thresholds = color.domain(); + const thresholds = domain; const thresholdFormat = tickFormat === undefined ? d => d : typeof tickFormat === "string" ? format(tickFormat) : tickFormat; x = scaleLinear() - .domain([-1, color.range().length - 1]) + .domain([-1, scaleRange.length - 1]) .rangeRound([marginLeft, width - marginRight]); svg.append("g") .selectAll("rect") - .data(color.range()) + .data(scaleRange) .join("rect") .attr("x", (d, i) => x(i - 1)) .attr("y", marginTop) @@ -101,12 +104,12 @@ export function legendRamp(color, { // Ordinal else { x = scaleBand() - .domain(color.domain()) + .domain(domain) .rangeRound([marginLeft, width - marginRight]); svg.append("g") .selectAll("rect") - .data(color.domain()) + .data(domain) .join("rect") .attr("x", x) .attr("y", marginTop) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 62d736839a..4e4eae69a7 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -82,11 +82,11 @@ export function legendSwatches(color, { if (columns !== null) { const elems = swatches.append("div") .style("columns", columns); - for (const value of color.domain()) { + for (const value of color.domain) { const d = elems.append("div").classed(`${uid}-item`, true); d.append("div") .classed(`${uid}-block`, true) - .style("background", color(value)); + .style("background", color.apply(value)); const label = format(value); d.append("div") .classed(`${uid}-label`, true) @@ -96,10 +96,10 @@ export function legendSwatches(color, { } else { swatches .selectAll() - .data(color.domain()) + .data(color.domain) .join("span") .classed(`${uid}-swatch`, true) - .style("--color", color) + .style("--color", color.apply) .text(format); } diff --git a/src/scales.js b/src/scales.js index 476c5e89d4..88dd8fb52c 100644 --- a/src/scales.js +++ b/src/scales.js @@ -310,7 +310,7 @@ export function exposeScales(scaleDescriptors) { }; } -function exposeScale({ +export function exposeScale({ scale, type, range, diff --git a/src/scales/diverging.js b/src/scales/diverging.js index 081868f394..a44943c86b 100644 --- a/src/scales/diverging.js +++ b/src/scales/diverging.js @@ -58,7 +58,8 @@ function ScaleD(key, scale, transform, channels, { else if (mindelta > maxdelta) max = transform.invert(mid + mindelta); } - scale.domain([min, pivot, max]).unknown(unknown).interpolator(interpolate); + domain = [min, pivot, max]; + scale.domain(domain).unknown(unknown).interpolator(interpolate); if (clamp) scale.clamp(clamp); if (nice) scale.nice(nice); return {type, interpolate, scale}; diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index cacd7aa63d..eeffccfb30 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -82,6 +82,16 @@ export default async function() { } }), + // Plot.plot({ + // color: { + // type: "diverging", + // domain: [-0.1, 0.1], + // scheme: "PiYG", + // label: "Daily change", + // tickFormat: "+%" + // } + // }).legend("color"), + Plot.legend({ color: { type: "diverging-sqrt", From dd53c0ca6a4b4aede31e2c07434c6febd1f9ccb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 16:02:38 +0100 Subject: [PATCH 48/72] add a test for Plot.legend with options --- test/output/legendColor.html | 19 +++++++++++++++++++ test/plots/legend-color.js | 17 ++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 51d25c9ba0..25e1b7fba4 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -410,6 +410,25 @@ +10% Daily change + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index eeffccfb30..2fa3978ac7 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -82,15 +82,14 @@ export default async function() { } }), - // Plot.plot({ - // color: { - // type: "diverging", - // domain: [-0.1, 0.1], - // scheme: "PiYG", - // label: "Daily change", - // tickFormat: "+%" - // } - // }).legend("color"), + Plot.plot({ + color: { + type: "diverging", + domain: [-0.1, 0.1], + scheme: "PiYG", + label: "Daily change" + } + }).legend("color", {tickFormat: "+%"}), Plot.legend({ color: { From f9a36171edf0a25a22dce0674f4ad556b1e201f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 17:43:19 +0100 Subject: [PATCH 49/72] styles --- src/legends/ramp.js | 7 +- src/legends/swatches.js | 28 +- src/plot.js | 7 +- src/style.js | 5 + test/output/legendColor.html | 650 ++++++++++++++++++----------------- 5 files changed, 356 insertions(+), 341 deletions(-) diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 1315609f11..b71dcddd2b 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,5 +1,6 @@ import {Scale} from "../scales.js"; import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; +import {addStyle} from "../style.js"; export function legendRamp(scale, { label, @@ -10,6 +11,7 @@ export function legendRamp(scale, { marginRight = 0, marginBottom = 16 + tickSize, marginLeft = 0, + style, ticks = width / 64, tickFormat, tickValues @@ -21,8 +23,9 @@ export function legendRamp(scale, { .attr("width", width) .attr("height", height) .attr("viewBox", [0, 0, width, height]) - .style("overflow", "visible") - .style("display", "block"); + .each(addStyle(style)); + if (svg.style("overflow") === "") svg.style("overflow", "visible"); + if (svg.style("display") === "") svg.style("display", "block"); let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height); let x; diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 4e4eae69a7..a515dde441 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,5 +1,5 @@ import {create} from "d3"; -import {maybeClassName} from "../style.js"; +import {addStyle, maybeClassName} from "../style.js"; // TODO: once we inline, is this smart variable handling any // better than inline styles? @@ -68,28 +68,28 @@ export function legendSwatches(color, { swatchHeight = swatchSize, marginLeft = 0, className, - uid = maybeClassName(className), - style = styles(uid), + style, width } = {}) { + className = maybeClassName(className); const swatches = create("div") - .classed(uid, true) + .classed(className, true) .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ width === undefined ? "" : ` width: ${width}px;` }`); - swatches.append("style").text(style); + swatches.append("style").text(styles(className)); if (columns !== null) { const elems = swatches.append("div") .style("columns", columns); for (const value of color.domain) { - const d = elems.append("div").classed(`${uid}-item`, true); + const d = elems.append("div").classed(`${className}-item`, true); d.append("div") - .classed(`${uid}-block`, true) + .classed(`${className}-block`, true) .style("background", color.apply(value)); const label = format(value); d.append("div") - .classed(`${uid}-label`, true) + .classed(`${className}-label`, true) .text(label) .attr("title", label); } @@ -98,16 +98,16 @@ export function legendSwatches(color, { .selectAll() .data(color.domain) .join("span") - .classed(`${uid}-swatch`, true) + .classed(`${className}-swatch`, true) .style("--color", color.apply) .text(format); } - return label == null - ? swatches.node() - : create("div") - .call(div => div.append("div") - .classed(`${uid}-title`, true) + return create("div") + .each(addStyle(style)) + .call(label == null ? () => {} + : div => div.append("div") + .classed(`${className}-title`, true) .text(label)) .call(div => div.append(() => swatches.node())) .node(); diff --git a/src/plot.js b/src/plot.js index 3145348ca4..0b99e0488b 100644 --- a/src/plot.js +++ b/src/plot.js @@ -5,7 +5,7 @@ import {figureWrap} from "./figure.js"; import {legend} 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 {addStyle, filterStyles, maybeClassName, offset} from "./style.js"; export function plot(options = {}) { const {facet, style, caption} = options; @@ -95,10 +95,7 @@ export function plot(options = {}) { white-space: pre; } `)) - .each(function() { - if (typeof style === "string") this.style = style; - else Object.assign(this.style, style); - }) + .each(addStyle(style)) .node(); for (const mark of marks) { diff --git a/src/style.js b/src/style.js index 0fddeb2dfe..fba12acb7c 100644 --- a/src/style.js +++ b/src/style.js @@ -162,3 +162,8 @@ export function maybeClassName(name) { if (!validClassName.test(name)) throw new Error(`invalid class name: ${name}`); return name; } + +export const addStyle = (style) => function() { + if (typeof style === "string") this.style = style; + else Object.assign(this.style, style); +}; diff --git a/test/output/legendColor.html b/test/output/legendColor.html index 25e1b7fba4..bbaf083b76 100644 --- a/test/output/legendColor.html +++ b/test/output/legendColor.html @@ -1,60 +1,62 @@
-
- ABCDEFGHIJ +
+
+ ABCDEFGHIJ +
@@ -170,119 +172,123 @@ I feel blue -
- DCBA +
+
+ DCBA +
-
- ABCD +
+
+ ABCD +
@@ -710,159 +716,163 @@ Age (years) -
- blueberriesorangesapples +
+
+ blueberriesorangesapples +
-
- -
-
-
-
Wholesale and Retail Trade
-
-
-
-
Manufacturing
-
-
-
-
Leisure and hospitality
-
-
-
-
Business services
-
-
-
-
Construction
-
-
-
-
Education and Health
-
-
-
-
Government
-
-
-
-
Finance
-
-
-
-
Self-employed
-
-
-
-
Other
+
+
+ +
+
+
+
Wholesale and Retail Trade
+
+
+
+
Manufacturing
+
+
+
+
Leisure and hospitality
+
+
+
+
Business services
+
+
+
+
Construction
+
+
+
+
Education and Health
+
+
+
+
Government
+
+
+
+
Finance
+
+
+
+
Self-employed
+
+
+
+
Other
+
From a09d7c0f81ab72d2f118a95d2a5bb659b09a849b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 21:37:03 +0100 Subject: [PATCH 50/72] clear up some confusion between scale options and legend options --- src/legends.js | 29 ++++---------- test/plots/legend-color.js | 79 +++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 60 deletions(-) diff --git a/src/legends.js b/src/legends.js index 7c7f5e3e2d..cf1ffa7ac9 100644 --- a/src/legends.js +++ b/src/legends.js @@ -4,27 +4,14 @@ import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { if (color != null) { return legendColor(exposeScale(Scale("color", undefined, color)), { -// ...color, - legend: color.legend, - label: color.label, - tickSize: color.tickSize, - width: color.width, - height: color.height, - marginTop: color.marginTop, - marginRight: color.marginRight, - marginBottom: color.marginBottom, - marginLeft: color.marginLeft, - ticks: color.ticks, - tickFormat: color.tickFormat, - tickValues: color.tickValues, - format: color.format, - className: color.className, - swatchSize: color.swatchSize, - swatchWidth: color.swatchWidth, - swatchHeight: color.swatchHeight, - columns: color.columns, - ...options - }); + // ...color, + label: color.label, + // ticks: color.ticks, // maybe? + // tickFormat: color.tickFormat, // maybe? + // tickValues: color.tickValues, // maybe? + // format: color.format, // maybe? + ...options + }); } throw new Error(`unsupported legend type`); } diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 2fa3978ac7..e64125f782 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -23,19 +23,19 @@ export default async function() { Plot.legend({ color: { - width: 400, type: "sqrt", scheme: "blues", range: [0.25, 1], - label: "I feel blue", - marginLeft: 150, - marginRight: 50 - } + label: "I feel blue" + }, + width: 400, + marginLeft: 150, + marginRight: 50 }), - Plot.legend({color: {domain: "DCBA", scheme: "rainbow", className: "swatches2"}}), + Plot.legend({color: {domain: "DCBA", scheme: "rainbow"}, className: "swatches2"}), - Plot.legend({color: {domain: "DCBA", reverse: true, className: "swatches3"}}), + Plot.legend({color: {domain: "DCBA", reverse: true}, className: "swatches3"}), Plot.legend({ color: Plot.plot({ @@ -77,9 +77,9 @@ export default async function() { type: "diverging", domain: [-0.1, 0.1], scheme: "PiYG", - label: "Daily change", - tickFormat: "+%" - } + label: "Daily change" + }, + tickFormat: "+%" }), Plot.plot({ @@ -96,9 +96,9 @@ export default async function() { type: "diverging-sqrt", domain: [-0.1, 0.1], scheme: "RdBu", - label: "Daily change", - tickFormat: "+%" - } + label: "Daily change" + }, + tickFormat: "+%" }), Plot.legend({ @@ -106,10 +106,10 @@ export default async function() { type: "log", domain: [1, 100], scheme: "Blues", - label: "Energy (joules)", - ticks: 10, - width: 380 - } + label: "Energy (joules)" + }, + ticks: 10, + width: 380 }), Plot.legend({ @@ -128,10 +128,10 @@ export default async function() { domain: d3.range(1000).map(d3.randomNormal.source(d3.randomLcg(42))(100, 20)), scheme: "Spectral", label: "Height (cm)", - tickFormat: ".0f", - width: 400, quantiles: 10 - } + }, + tickFormat: ".0f", + width: 400 }), Plot.legend({ @@ -139,9 +139,9 @@ export default async function() { type: "threshold", domain: [2.5, 3.1, 3.5, 3.9, 6, 7, 8, 9.5], scheme: "RdBu", - label: "Unemployment rate (%)", - tickSize: 0 - } + label: "Unemployment rate (%)" + }, + tickSize: 0 }), Plot.legend({ @@ -158,10 +158,10 @@ export default async function() { "≥80" ], scheme: "Spectral", - label: "Age (years)", - className: "swatches4", - tickSize: 0 - } + label: "Age (years)" + }, + className: "swatches4", + tickSize: 0 }), Plot.legend({ @@ -178,15 +178,16 @@ export default async function() { "≥80" ], scheme: "Spectral", - label: "Age (years)", - tickSize: 0, - legend: "ramp", - width: 400 - } + label: "Age (years)" + }, + tickSize: 0, + legend: "ramp", + width: 400 }), Plot.legend({ - color: {domain: ["blueberries", "oranges", "apples"], scheme: "category10", className: "swatches5"} + color: {domain: ["blueberries", "oranges", "apples"], scheme: "category10"}, + className: "swatches5" }), Plot.legend({ @@ -202,12 +203,12 @@ export default async function() { "Finance", "Self-employed", "Other" - ], - columns: "180px", // responsive! - width: 960, - className: "swatches6" - } - }) + ] + }, + columns: "180px", // responsive! + width: 960, + className: "swatches6" + }) ); From 6ccf13fea29b9a56bd1e3004d410da4c567e52d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 18 Nov 2021 21:37:10 +0100 Subject: [PATCH 51/72] className --- src/legends/swatches.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/legends/swatches.js b/src/legends/swatches.js index a515dde441..f7bfc5b2a7 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -3,8 +3,8 @@ import {addStyle, maybeClassName} from "../style.js"; // TODO: once we inline, is this smart variable handling any // better than inline styles? -const styles = uid => ` -.${uid} { +const styles = className => ` +.${className} { display: flex; align-items: center; margin-left: var(--marginLeft); @@ -13,37 +13,37 @@ const styles = uid => ` margin-bottom: 0.5em; } -.${uid} > div { +.${className} > div { width: 100%; } -.${uid}-item { +.${className}-item { break-inside: avoid; display: flex; align-items: center; padding-bottom: 1px; } -.${uid}-label { +.${className}-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - var(--swatchWidth) - 0.5em); } -.${uid}-block { +.${className}-block { width: var(--swatchWidth); height: var(--swatchHeight); margin: 0 0.5em 0 0; } -.${uid}-swatch { +.${className}-swatch { display: inline-flex; align-items: center; margin-right: 1em; } -.${uid}-swatch::before { +.${className}-swatch::before { content: ""; width: var(--swatchWidth); height: var(--swatchHeight); @@ -51,7 +51,7 @@ const styles = uid => ` background: var(--color); } -.${uid}-title { +.${className}-title { font-weight: bold; font-family: sans-serif; font-size: 10px; From 6fb58a95b24279f9feb75512458e6f317f29a02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 22 Nov 2021 16:13:06 +0100 Subject: [PATCH 52/72] apply() rather than color() --- src/legends/ramp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legends/ramp.js b/src/legends/ramp.js index b71dcddd2b..6da3682ae7 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -118,7 +118,7 @@ export function legendRamp(scale, { .attr("y", marginTop) .attr("width", Math.max(0, x.bandwidth() - 1)) .attr("height", height - marginTop - marginBottom) - .attr("fill", color); + .attr("fill", apply); tickAdjust = () => {}; } From 5e6ef47c329db3a950e9eba39541635392594589 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 12:53:54 -0800 Subject: [PATCH 53/72] Update README --- README.md | 44 ++++---------------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 6ff8c33a12..0e4ecb1914 100644 --- a/README.md +++ b/README.md @@ -228,13 +228,12 @@ The scale object is undefined if the associated plot has no scale with the given Given a chart’s *color* scale, Plot can generate a legend: -#### chart.legend(*name*[, *options*]) +#### *chart*.legend(*name*[, *options*]) -A suitable legend is returned for the chart’s scale name; for now only *color* legends are supported. +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". +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: -The swatches can be configured with the following options: * *options*.**columns** - the number of swatches per row * *options*.**format** - a format function for the labels * *options*.**swatchSize** - the size of the swatch (if square) @@ -244,42 +243,7 @@ The swatches can be configured with the following options: * *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*.**tickSize** - the tick size -* *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 -* *options*.**ticks** - number of ticks -* *options*.**tickFormat** - a format function for the legend’s ticks -* *options*.**tickValues** - the legend’s tick values - -#### Plot.legend({*name*: *scale*, ...*options*}) - -Builds a legend from a scale description object, passing the options described in the previous section. The only supported name for now is *color*. - -### Legends - -Given a chart’s *color* scale, Plot can generate a legend: - -#### chart.legend(*name*[, *options*]) -A suitable legend is returned for the chart’s scale 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*.**columns** - the number of swatches per row -* *options*.**format** - 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*.**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*.**tickSize** - the tick size * *options*.**width** - the legend’s width @@ -292,7 +256,7 @@ Continuous color legends are rendered as a ramp, and can be configured with the * *options*.**tickFormat** - a format function for the legend’s ticks * *options*.**tickValues** - the legend’s tick values -#### Plot.legend({*name*: *scale*, ...*options*}) +#### Plot.legend({[*name*]: *scale*, ...*options*}) Builds a legend from a scale description object, passing the options described in the previous section. The only supported name for now is *color*. From ff394beda31a7c1672064cca95034db80204afab Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 13:01:21 -0800 Subject: [PATCH 54/72] revert figure changes --- src/figure.js | 12 ------------ src/plot.js | 10 ++++++++-- test/output/figcaption.html | 2 +- test/output/figcaptionHtml.html | 2 +- 4 files changed, 10 insertions(+), 16 deletions(-) delete mode 100644 src/figure.js diff --git a/src/figure.js b/src/figure.js deleted file mode 100644 index f14884e7b6..0000000000 --- a/src/figure.js +++ /dev/null @@ -1,12 +0,0 @@ - -// Wrap the plot in a figure with a caption, if desired. -export function figureWrap(svg, {width}, caption) { - if (caption == null) return svg; - const figure = document.createElement("figure"); - figure.style = `max-width: ${width}px`; - figure.appendChild(svg); - const figcaption = document.createElement("figcaption"); - figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); - figure.appendChild(figcaption); - return figure; -} diff --git a/src/plot.js b/src/plot.js index 0b99e0488b..20ac67d088 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,7 +1,6 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; -import {figureWrap} from "./figure.js"; import {legend} from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; @@ -107,7 +106,14 @@ export function plot(options = {}) { } // Wrap the plot in a figure with a caption, if desired. - const figure = figureWrap(svg, dimensions, caption); + let figure = svg; + if (caption != null) { + figure = document.createElement("figure"); + figure.appendChild(svg); + const figcaption = figure.appendChild(document.createElement("figcaption")); + figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); + } + figure.scale = exposeScales(scaleDescriptors); figure.legend = (type, options) => legend({...options, [type]: figure.scale(type)}); return figure; diff --git a/test/output/figcaption.html b/test/output/figcaption.html index df0b4dab6f..5dd48dad7e 100644 --- a/test/output/figcaption.html +++ b/test/output/figcaption.html @@ -1,4 +1,4 @@ -
+
+ 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 @@ + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + \ 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 @@ + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + \ 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 @@ + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + \ 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 @@ + + + + + + −10% + + + −5% + + + +0% + + + +5% + + + +10% + Daily change + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Legend + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Legend + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Scale + + \ 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 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + \ 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 @@ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + + + + + + + + + + + + 10 + + + \ 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 @@ + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + 10 + + + \ No newline at end of file diff --git a/test/output/colorLegendMargins.svg b/test/output/colorLegendMargins.svg new file mode 100644 index 0000000000..99328b0b2f --- /dev/null +++ b/test/output/colorLegendMargins.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + I feel blue + + \ 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 @@ + + + + + + + + + + + + + + + + + A + + + B + + + C + + + D + + + E + + + F + + + G + + + H + + + I + + + J + + + \ 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 @@ + + + + + + + + + + + + + + <20 + + + 20-29 + + + 30-39 + + + 40-49 + + + 50-59 + + + 60-69 + + + ≥70 + Age (years) + + \ 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 @@ + + + + + + + + + + + + + + + + + J + + + I + + + H + + + G + + + F + + + E + + + D + + + C + + + B + + + A + + + \ 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 @@ + + + + + + + + + + + + + + + + + A + + + B + + + C + + + D + + + E + + + F + + + G + + + H + + + I + + + J + + + \ No newline at end of file diff --git a/test/output/colorLegendQuantile.svg b/test/output/colorLegendQuantile.svg new file mode 100644 index 0000000000..90d569a932 --- /dev/null +++ b/test/output/colorLegendQuantile.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + 200 + + + 800 + + + 1,800 + + + 3,201 + + + 5,001 + + + 7,201 + Inferno + + \ No newline at end of file diff --git a/test/output/colorLegendQuantileImplicit.svg b/test/output/colorLegendQuantileImplicit.svg new file mode 100644 index 0000000000..90d569a932 --- /dev/null +++ b/test/output/colorLegendQuantileImplicit.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + 200 + + + 800 + + + 1,800 + + + 3,201 + + + 5,001 + + + 7,201 + Inferno + + \ No newline at end of file diff --git a/test/output/colorLegendQuantitative.svg b/test/output/colorLegendQuantitative.svg new file mode 100644 index 0000000000..72075720ee --- /dev/null +++ b/test/output/colorLegendQuantitative.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + \ No newline at end of file diff --git a/test/output/colorLegendQuantitativeScheme.svg b/test/output/colorLegendQuantitativeScheme.svg new file mode 100644 index 0000000000..3843168996 --- /dev/null +++ b/test/output/colorLegendQuantitativeScheme.svg @@ -0,0 +1,37 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + + + \ No newline at end of file diff --git a/test/output/colorLegendSqrt.svg b/test/output/colorLegendSqrt.svg new file mode 100644 index 0000000000..bcbee1dc2c --- /dev/null +++ b/test/output/colorLegendSqrt.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + \ No newline at end of file diff --git a/test/output/colorLegendSqrtPiecewise.svg b/test/output/colorLegendSqrtPiecewise.svg new file mode 100644 index 0000000000..30468ccef7 --- /dev/null +++ b/test/output/colorLegendSqrtPiecewise.svg @@ -0,0 +1,34 @@ + + + + + + −100 + + + −50 + + + 0 + + + 50 + + + 100 + + + \ No newline at end of file diff --git a/test/output/colorLegendThreshold.svg b/test/output/colorLegendThreshold.svg new file mode 100644 index 0000000000..12dbd4dc65 --- /dev/null +++ b/test/output/colorLegendThreshold.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + Viridis + + \ No newline at end of file diff --git a/test/output/colorLegendThresholdTickSize.svg b/test/output/colorLegendThresholdTickSize.svg new file mode 100644 index 0000000000..f20b7a5dcd --- /dev/null +++ b/test/output/colorLegendThresholdTickSize.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + 2.5 + + + 3.1 + + + 3.5 + + + 3.9 + + + 6 + + + 7 + + + 8 + + + 9.5 + Unemployment rate (%) + + \ No newline at end of file diff --git a/test/output/legendColor.html b/test/output/legendColor.html deleted file mode 100644 index bbaf083b76..0000000000 --- a/test/output/legendColor.html +++ /dev/null @@ -1,879 +0,0 @@ -
-
-
- ABCDEFGHIJ -
-
- - - - - - - - - - - - - - - A - - - B - - - C - - - D - - - E - - - F - - - G - - - H - - - I - - - J - - - - - - - 0 - - - 2 - - - 4 - - - 6 - - - 8 - - - 10 - - - - - - - 0 - - - 2 - - - 4 - - - 6 - - - 8 - - - 10 - scale label - - - - - - 0 - - - 2 - - - 4 - - - 6 - - - 8 - - - 10 - legend label - - - - I feel blue - -
-
- DCBA -
-
-
-
- ABCD -
-
- - - - - - - - - - - - 200 - - - 800 - - - 1,800 - - - 3,201 - - - 5,001 - - - 7,201 - quantiles! - - - - - - - - - - - - - - - 2.0 - - - 3.0 - - - 4.0 - - - 5.0 - - - 6.0 - - - 7.0 - - - 8.0 - thresholds! - - - - - - 0 - - - 20 - - - 40 - - - 60 - - - 80 - - - 100 - Temperature (°F) - - - - - - 0.0 - - - 0.2 - - - 0.4 - - - 0.6 - - - 0.8 - - - 1.0 - Speed (kts) - - - - - - −10% - - - −5% - - - +0% - - - +5% - - - +10% - Daily change - - - - - - −10% - - - −5% - - - +0% - - - +5% - - - +10% - Daily change - - - - - - −10% - - - −5% - - - +0% - - - +5% - - - +10% - Daily change - - - - - - 1 - - - 2 - - - 3 - - - 4 - - - 5 - - - - - - - - - - - - - - - 10 - - - 20 - - - 30 - - - 40 - - - 50 - - - - - - - - - - - - - - - 100 - Energy (joules) - - - - - - −100 - - - −50 - - - 0 - - - 50 - - - 100 - Temperature (°C) - - - - - - - - - - - - - - - - - 73 - - - 82 - - - 90 - - - 94 - - - 100 - - - 105 - - - 112 - - - 118 - - - 127 - Height (cm) - - - - - - - - - - - - - - - - 2.5 - - - 3.1 - - - 3.5 - - - 3.9 - - - 6 - - - 7 - - - 8 - - - 9.5 - Unemployment rate (%) - - -
-
Age (years)
-
- <1010-1920-2930-3940-4950-5960-6970-79≥80 -
-
- - - - - - - - - - - - - - <10 - - - 10-19 - - - 20-29 - - - 30-39 - - - 40-49 - - - 50-59 - - - 60-69 - - - 70-79 - - - ≥80 - Age (years) - - -
-
- blueberriesorangesapples -
-
-
-
- -
-
-
-
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/plot.js b/test/plot.js index 5e13bd8fc6..03a09d40a6 100644 --- a/test/plot.js +++ b/test/plot.js @@ -8,16 +8,20 @@ import * as plots from "./plots/index.js"; for (const [name, plot] of Object.entries(plots)) { it(`plot ${name}`, async () => { const root = await plot(); - const [ext, svg] = root.tagName === "svg" ? ["svg", root] : ["html", root.querySelector("svg")]; + const ext = root.tagName === "svg" ? "svg" : "html"; + const svg = root.tagName === "svg" ? root : root.querySelector("svg"); if (svg) { svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); - const uid = svg.getAttribute("class"); - svg.setAttribute("class", "plot"); - const style = svg.querySelector("style"); - if (style) { - style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); + } + const style = root.querySelector("style"); + if (style) { + const parent = style.parentNode; + const uid = parent.getAttribute("class"); + for (const child of [parent, ...parent.querySelectorAll("[class]")]) { + child.setAttribute("class", child.getAttribute("class").replace(new RegExp(`\\b${uid}\\b`, "g"), "plot")); } + style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); } const actual = beautify.html(root.outerHTML, {indent_size: 2}); const outfile = path.resolve("./test/output", `${path.basename(name, ".js")}.${ext}`); diff --git a/test/plots/index.html b/test/plots/index.html index ce9ec63869..ca01213bca 100644 --- a/test/plots/index.html +++ b/test/plots/index.html @@ -45,6 +45,6 @@ document.body.append(select); -tests[select.value]().then(chart => document.body.append(chart)); +Promise.resolve(tests[select.value]()).then(chart => document.body.append(chart)); diff --git a/test/plots/index.js b/test/plots/index.js index c867726a6d..510e9b7957 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -52,7 +52,7 @@ export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; -export {default as legendColor} from "./legend-color.js"; +export * from "./legend-color.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index e64125f782..a7d926c363 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -1,216 +1,237 @@ import * as d3 from "d3"; import * as Plot from "@observablehq/plot"; -export default async function() { - const div = document.createElement("div"); - - const ordinal = Plot.dot("ABCDEFGHIJ", {x: 0, fill: d => d}).plot(); - - div.append( - ordinal.legend("color", {className: "swatches1"}), - - ordinal.legend("color", {legend: "ramp"}), - - Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot().legend("color"), - - Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ - color: {label: "scale label"} - }).legend("color"), - - Plot.dot({length: 22}, {x: 0, fill: (_, i) => i % 11}).plot({ - color: {label: "scale label"} - }).legend("color", {label: "legend label"}), - - Plot.legend({ - color: { - type: "sqrt", - scheme: "blues", - range: [0.25, 1], - label: "I feel blue" - }, - width: 400, - marginLeft: 150, - marginRight: 50 - }), - - Plot.legend({color: {domain: "DCBA", scheme: "rainbow"}, className: "swatches2"}), - - Plot.legend({color: {domain: "DCBA", reverse: true}, className: "swatches3"}), - - Plot.legend({ - color: Plot.plot({ - marks: [ - Plot.dotX(d3.range(100), { - x: (i) => i, - y: (i) => i ** 2, - fill: (i) => i ** 2 - }) - ], - color: {type: "quantile", scheme: "inferno", quantiles: 7} - }).scale("color"), - width: 300, - label: "quantiles!", - tickFormat: ",d" - }), - - Plot.legend({ - color: { - type: "threshold", - domain: d3.ticks(2, 8, 5), - scheme: "viridis" - }, - width: 300, - label: "thresholds!", - tickFormat: (d) => d.toFixed(1) - }), - - Plot.legend({ - color: {scheme: "viridis", domain: [0, 100], label: "Temperature (°F)"} - }), - - Plot.legend({ - color: {scheme: "Turbo", type: "sqrt", domain: [0, 1], label: "Speed (kts)"} - }), - - Plot.legend({ - color: { - type: "diverging", - domain: [-0.1, 0.1], - scheme: "PiYG", - label: "Daily change" - }, - tickFormat: "+%" - }), - - Plot.plot({ - color: { - type: "diverging", - domain: [-0.1, 0.1], - scheme: "PiYG", - label: "Daily change" - } - }).legend("color", {tickFormat: "+%"}), - - Plot.legend({ - color: { - type: "diverging-sqrt", - domain: [-0.1, 0.1], - scheme: "RdBu", - label: "Daily change" - }, - tickFormat: "+%" - }), - - Plot.legend({ - color: { - type: "log", - domain: [1, 100], - scheme: "Blues", - label: "Energy (joules)" - }, - ticks: 10, - width: 380 - }), - - Plot.legend({ - color: { - type: "sqrt", - domain: [-100, 0, 100], - range: ["blue", "white", "red"], - label: "Temperature (°C)", - interpolate: "rgb" - } - }), - - Plot.legend({ - color: { - type: "quantile", - domain: d3.range(1000).map(d3.randomNormal.source(d3.randomLcg(42))(100, 20)), - scheme: "Spectral", - label: "Height (cm)", - quantiles: 10 - }, - tickFormat: ".0f", - width: 400 - }), - - Plot.legend({ - color: { - type: "threshold", - domain: [2.5, 3.1, 3.5, 3.9, 6, 7, 8, 9.5], - scheme: "RdBu", - label: "Unemployment rate (%)" - }, - tickSize: 0 - }), - - Plot.legend({ - color: { - domain: [ - "<10", - "10-19", - "20-29", - "30-39", - "40-49", - "50-59", - "60-69", - "70-79", - "≥80" - ], - scheme: "Spectral", - label: "Age (years)" - }, - className: "swatches4", - tickSize: 0 - }), - - Plot.legend({ - color: { - domain: [ - "<10", - "10-19", - "20-29", - "30-39", - "40-49", - "50-59", - "60-69", - "70-79", - "≥80" - ], - scheme: "Spectral", - label: "Age (years)" - }, - tickSize: 0, - legend: "ramp", - width: 400 - }), - - Plot.legend({ - color: {domain: ["blueberries", "oranges", "apples"], scheme: "category10"}, - className: "swatches5" - }), - - Plot.legend({ - color: { - domain: [ - "Wholesale and Retail Trade", - "Manufacturing", - "Leisure and hospitality", - "Business services", - "Construction", - "Education and Health", - "Government", - "Finance", - "Self-employed", - "Other" - ] - }, - columns: "180px", // responsive! - width: 960, - className: "swatches6" - }) - - ); - - return div; +export function colorLegendCategorical() { + return Plot.plot({color: {domain: "ABCDEFGHIJ"}}).legend("color"); +} + +export function colorLegendCategoricalColumns() { + return Plot.legend({ + color: { + domain: [ + "Wholesale and Retail Trade", + "Manufacturing", + "Leisure and hospitality", + "Business services", + "Construction", + "Education and Health", + "Government", + "Finance", + "Self-employed", + "Other" + ] + }, + label: "Hello", + columns: "180px" + }); +} + +export function colorLegendCategoricalScheme() { + return Plot.plot({color: {domain: "ABCDEFGHIJ", scheme: "category10"}}).legend("color"); +} + +export function colorLegendCategoricalReverse() { + return Plot.plot({color: {domain: "ABCDEFGHIJ", reverse: true}}).legend("color"); +} + +export function colorLegendOrdinal() { + return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ"}}).legend("color"); +} + +export function colorLegendOrdinalRamp() { + return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ"}}).legend("color", {legend: "ramp"}); +} + +export function colorLegendOrdinalRampTickSize() { + return Plot.legend({ + color: { + domain: [ + "<20", + "20-29", + "30-39", + "40-49", + "50-59", + "60-69", + "≥70" + ], + scheme: "Spectral", + label: "Age (years)" + }, + legend: "ramp", + tickSize: 0 + }); +} + +export function colorLegendOrdinalReverseRamp() { + return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ", reverse: true}}).legend("color", {legend: "ramp"}); +} + +export function colorLegendOrdinalScheme() { + return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ", scheme: "rainbow"}}).legend("color"); +} + +export function colorLegendOrdinalSchemeRamp() { + return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ", scheme: "rainbow"}}).legend("color", {legend: "ramp"}); +} + +export function colorLegendQuantitative() { + return Plot.plot({color: {domain: [0, 10]}}).legend("color"); +} + +export function colorLegendQuantitativeScheme() { + return Plot.plot({color: {scheme: "blues", domain: [0, 1]}}).legend("color"); +} + +export function colorLegendLinear() { + return Plot.plot({color: {type: "linear", domain: [0, 10]}}).legend("color"); +} + +export function colorLegendSqrt() { + return Plot.plot({color: {type: "sqrt", domain: [0, 10]}}).legend("color"); +} + +export function colorLegendSqrtPiecewise() { + return Plot.plot({color: {type: "sqrt", domain: [-100, 0, 100], range: ["blue", "white", "red"]}}).legend("color"); +} + +export function colorLegendInterpolate() { + return Plot.plot({color: {domain: [0, 10], range: ["steelblue", "orange"], interpolate: "hcl"}}).legend("color"); +} + +export function colorLegendInterpolateSqrt() { + return Plot.plot({color: {type: "sqrt", domain: [0, 10]}}).legend("color"); +} + +export function colorLegendLog() { + return Plot.plot({color: {type: "log", domain: [1, 10]}}).legend("color"); +} + +export function colorLegendLogTicks() { + return Plot.plot({color: {type: "log", domain: [1, 10]}}).legend("color", {ticks: 10}); +} + +export function colorLegendLabelScale() { + return Plot.plot({color: {type: "linear", domain: [0, 10], label: "Scale"}}).legend("color"); +} + +export function colorLegendLabelLegend() { + return Plot.plot({color: {type: "linear", domain: [0, 10]}}).legend("color", {label: "Legend"}); +} + +export function colorLegendLabelBoth() { + return Plot.plot({color: {type: "linear", domain: [0, 10], label: "Scale"}}).legend("color", {label: "Legend"}); +} + +export function colorLegendMargins() { + return Plot.legend({ + color: { + type: "sqrt", + domain: [0, 10], + label: "I feel blue" + }, + width: 400, + marginLeft: 150, + marginRight: 50 + }); +} + +export function colorLegendThreshold() { + return Plot.legend({ + color: { + type: "threshold", + scheme: "viridis", + domain: d3.range(1, 10), + label: "Viridis" + } + }); +} + +export function colorLegendThresholdTickSize() { + return Plot.legend({ + color: { + type: "threshold", + domain: [2.5, 3.1, 3.5, 3.9, 6, 7, 8, 9.5], + scheme: "RdBu", + label: "Unemployment rate (%)" + }, + tickSize: 0 + }); +} + +// This quantile scale is implicitly converted to a threshold scale! +export function colorLegendQuantile() { + return Plot.legend({ + color: { + type: "quantile", + scheme: "inferno", + domain: d3.range(100).map(i => i ** 2), + quantiles: 7, + label: "Inferno" + }, + tickFormat: ",d" + }); +} + +// This quantile scale is implicitly converted to a threshold scale! +export function colorLegendQuantileImplicit() { + return Plot.plot({ + color: { + type: "quantile", + scheme: "inferno", + quantiles: 7, + label: "Inferno" + }, + marks: [ + Plot.dot(d3.range(100), {fill: i => i ** 2}) + ] + }).legend("color", { + tickFormat: ",d" + }); +} + +export function colorLegendDiverging() { + return Plot.legend({ + color: { + type: "diverging", + domain: [-0.1, 0.1], + scheme: "PiYG", + label: "Daily change" + }, + tickFormat: "+%" + }); +} + +export function colorLegendDivergingPivot() { + return Plot.legend({ + color: { + type: "diverging", + domain: [1, 4], + pivot: 3, + scheme: "PiYG" + } + }); +} + +export function colorLegendDivergingPivotAsymmetric() { + return Plot.legend({ + color: { + type: "diverging", + symmetric: false, + domain: [1, 4], + pivot: 3, + scheme: "PiYG" + } + }); +} + +export function colorLegendDivergingSqrt() { + return Plot.legend({ + color: { + type: "diverging-sqrt", + domain: [-0.1, 0.1], + scheme: "PiYG", + label: "Daily change" + }, + tickFormat: "+%" + }); } diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 945c946b58..5a4c7a4340 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -277,6 +277,7 @@ it("plot(…).scale(name).unknown reflects the given unknown option for a diverg const plot = Plot.dotX(gistemp, {x: "Date", fill: "Anomaly"}).plot({color: {type: "diverging", symmetric: false, unknown: "black"}}); scaleEqual(plot.scale("color"), { type: "diverging", + symmetric: false, domain: [-0.78, 1.35], pivot: 0, clamp: false, @@ -382,6 +383,7 @@ it("plot(…).scale('color') can return an asymmetric diverging scale", async () const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging", symmetric: false}}); scaleEqual(plot.scale("color"), { type: "diverging", + symmetric: false, domain: [-0.78, 1.35], pivot: 0, interpolate: d3.interpolateRdBu, @@ -395,6 +397,7 @@ it("plot(…).scale('color') can return a symmetric diverging scale", async () = const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging"}}); scaleEqual(plot.scale("color"), { type: "diverging", + symmetric: false, domain: [-1.35, 1.35], interpolate: d3.interpolateRdBu, pivot: 0, @@ -409,6 +412,7 @@ it("plot(…).scale('color') can return a diverging scale with an explicit range const {interpolate, ...color} = plot.scale("color"); scaleEqual(color, { type: "diverging", + symmetric: false, domain: [-0.78, 1.35], pivot: 0, clamp: false, @@ -426,6 +430,7 @@ it("plot(…).scale('color') can return a diverging scale with an explicit schem const {interpolate, ...color} = plot.scale("color"); scaleEqual(color, { type: "diverging", + symmetric: false, domain: [-0.78, 1.35], pivot: 0, clamp: false, @@ -442,6 +447,7 @@ it("plot(…).scale('color') can return a transformed diverging scale", async () const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging", transform, symmetric: false}}); scaleEqual(plot.scale("color"), { type: "diverging", + symmetric: false, domain: [-78, 135], pivot: 0, transform, @@ -457,6 +463,7 @@ it("plot(…).scale('color') can return a transformed symmetric diverging scale" const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging", transform}}); scaleEqual(plot.scale("color"), { type: "diverging", + symmetric: false, domain: [-135, 135], pivot: 0, transform, @@ -471,6 +478,7 @@ it("plot(…).scale('color') can return an asymmetric diverging pow scale with a const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging-sqrt", symmetric: false, scheme: "piyg"}}); scaleEqual(plot.scale("color"), { type: "diverging-pow", + symmetric: false, exponent: 0.5, domain: [-0.78, 1.35], pivot: 0, @@ -485,6 +493,7 @@ it("plot(…).scale('color') can return an asymmetric diverging pow scale with a const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging-pow", exponent: 2, symmetric: false, scheme: "piyg"}}); scaleEqual(plot.scale("color"), { type: "diverging-pow", + symmetric: false, exponent: 2, domain: [-0.78, 1.35], pivot: 0, @@ -499,6 +508,7 @@ it("plot(…).scale('color') can return an asymmetric diverging symlog scale wit const plot = Plot.dot(gistemp, {x: "Date", stroke: "Anomaly"}).plot({color: {type: "diverging-symlog", constant: 2, symmetric: false, scheme: "piyg"}}); scaleEqual(plot.scale("color"), { type: "diverging-symlog", + symmetric: false, constant: 2, domain: [-0.78, 1.35], pivot: 0, @@ -513,6 +523,7 @@ it("plot(…).scale('color') can return an asymmetric diverging log scale with a const plot = Plot.dot(aapl, {x: "Date", stroke: "Volume"}).plot({color: {type: "diverging-log", pivot: 1e8, base: 10, symmetric: false, scheme: "piyg"}}); scaleEqual(plot.scale("color"), { type: "diverging-log", + symmetric: false, base: 10, domain: [11475900, 266380800], pivot: 100000000, @@ -528,6 +539,7 @@ it("plot(…).scale('color') can return an asymmetric diverging log scale with a const plot = Plot.dot(aapl, {x: "Date", stroke: "Volume"}).plot({color: {type: "diverging-log", transform, pivot: -1e8, base: 10, symmetric: false, scheme: "piyg"}}); scaleEqual(plot.scale("color"), { type: "diverging-log", + symmetric: false, base: 10, domain: [-266380800, -11475900], pivot: -100000000, From 9f0fc768bec1daba22e489ed27c4c2e63df17f1e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 16:06:10 -0800 Subject: [PATCH 59/72] stringify and lowercase legend option --- src/legends/color.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/legends/color.js b/src/legends/color.js index d1770ffc4e..b3bb1d41fc 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -1,14 +1,13 @@ import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; -export function legendColor(color, {legend, ...options}) { - if (legend === undefined) legend = color.type === "ordinal" ? "swatches" : "ramp"; - switch (legend) { - case "swatches": - return legendSwatches(color, options); - case "ramp": - return legendRamp(color, options); - default: - throw new Error(`unknown color legend type: ${legend}`); +export function legendColor(color, { + legend = color.type === "ordinal" ? "swatches" : "ramp", + ...options +}) { + switch (`${legend}`.toLowerCase()) { + case "swatches": return legendSwatches(color, options); + case "ramp": return legendRamp(color, options); + default: throw new Error(`unknown legend type: ${legend}`); } } From 6e1b5e8d13820520c16df00b85718136f0d2633d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 16:46:25 -0800 Subject: [PATCH 60/72] normalizeScale --- src/legends.js | 15 +++++++-------- src/legends/ramp.js | 11 +++++------ src/legends/swatches.js | 4 ++-- src/scales.js | 19 ++++++++++++------- src/scales/diverging.js | 2 +- src/scales/quantitative.js | 2 +- 6 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/legends.js b/src/legends.js index e9ab17df9a..cc9ab3f782 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,19 +1,18 @@ -import {exposeScale, Scale} from "./scales.js"; +import {normalizeScale} from "./scales.js"; import {legendColor} from "./legends/color.js"; export function legend({color, ...options}) { if (color != null) { - return legendColor(exposeScale(Scale("color", undefined, color)), { - // ...color, + return legendColor(normalizeScale("color", color), { label: color.label, - // ticks: color.ticks, // maybe? - // tickFormat: color.tickFormat, // maybe? - // tickValues: color.tickValues, // maybe? - // format: color.format, // maybe? + // TODO ticks + // TODO tickFormat + // TODO tickValues + // TODO format ...options }); } - throw new Error(`unsupported legend type`); + throw new Error("unsupported legend type"); } export function exposeLegends(type, options) { diff --git a/src/legends/ramp.js b/src/legends/ramp.js index e193742604..860f16524a 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,8 +1,7 @@ -import {Scale} from "../scales.js"; import {create, quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3"; import {applyInlineStyles, maybeClassName} from "../style.js"; -export function legendRamp(scale, { +export function legendRamp(color, { label, tickSize = 6, width = 240, @@ -45,7 +44,7 @@ export function legendRamp(scale, { let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height); let x; - const {type, domain, range, interpolate, apply} = scale; + const {type, domain, range, interpolate, scale, pivot} = color; // Continuous if (interpolate) { @@ -62,11 +61,11 @@ export function legendRamp(scale, { // 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 = Scale("color", undefined, scale).scale.rangeRound( + x = scale.copy().rangeRound( quantize( interpolateNumber(marginLeft, width - marginRight), Math.min( - domain.length + (scale.pivot !== undefined), + domain.length + (pivot !== undefined), range === undefined ? Infinity : range.length ) ) @@ -124,7 +123,7 @@ export function legendRamp(scale, { .attr("y", marginTop) .attr("width", Math.max(0, x.bandwidth() - 1)) .attr("height", height - marginTop - marginBottom) - .attr("fill", apply); + .attr("fill", scale); tickAdjust = () => {}; } diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 502ee0f886..216c24d51f 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -48,7 +48,7 @@ export function legendSwatches(color, { .data(color.domain) .join("div") .attr("class", `${className}-swatch`) - .style("--color", color.apply) + .style("--color", color.scale) .call(item => item.append("div") .attr("class", `${className}-label`) .attr("title", format) @@ -72,7 +72,7 @@ export function legendSwatches(color, { .data(color.domain) .join("span") .attr("class", `${className}-swatch`) - .style("--color", color.apply) + .style("--color", color.scale) .text(format); } diff --git a/src/scales.js b/src/scales.js index 750162c33d..47c989bb88 100644 --- a/src/scales.js +++ b/src/scales.js @@ -118,7 +118,11 @@ function piecewiseRange(scale) { return Array.from({length}, (_, i) => start + i / (length - 1) * (end - start)); } -export function Scale(key, channels = [], options = {}) { +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 @@ export 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}`); } } @@ -310,21 +314,22 @@ export function exposeScales(scaleDescriptors) { }; } -export function exposeScale({ +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 @@ -336,7 +341,7 @@ export function exposeScale({ ...scale.clamp && {clamp: scale.clamp()}, // diverging (always asymmetric; we never want to apply the symmetric transform twice) - ...isDivergingScale({type}) && (([min, pivot, max]) => ({domain: [min, max], pivot, symmetric: false}))(domain), + ...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}; From a2ca185acafb6fb983aece4334f9affb8b6a25df Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 18:11:03 -0800 Subject: [PATCH 61/72] inherit scale options --- src/axis.js | 16 +++++---- src/legends.js | 34 ++++++++++++------- src/legends/swatches.js | 10 +++--- src/plot.js | 9 +---- test/output/colorLegendOrdinalTickFormat.html | 34 +++++++++++++++++++ test/plots/legend-color.js | 11 +++--- 6 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 test/output/colorLegendOrdinalTickFormat.html diff --git a/src/axis.js b/src/axis.js index bdb4720c31..1fb1206945 100644 --- a/src/axis.js +++ b/src/axis.js @@ -202,14 +202,18 @@ function gridFacetY(index, fx, tx) { .attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join("")); } +// 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 === "string" ? (isTemporal(domain) ? utcFormat : format) + : constant)(tickFormat); +} + 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) - : constant)(tickFormat); + tickFormat = maybeTickFormat(tickFormat, scale.domain()); } return axis(scale) .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat) diff --git a/src/legends.js b/src/legends.js index cc9ab3f782..d492ee5d58 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,20 +1,28 @@ import {normalizeScale} from "./scales.js"; import {legendColor} from "./legends/color.js"; -export function legend({color, ...options}) { - if (color != null) { - return legendColor(normalizeScale("color", color), { - label: color.label, - // TODO ticks - // TODO tickFormat - // TODO tickValues - // TODO format - ...options - }); +const legendRegistry = new Map([ + ["color", legendColor] +]); + +export function legend(options = {}) { + for (const [key, value] of legendRegistry) { + const scale = options[key]; + if (scale != null) { + return value(normalizeScale(key, scale), legendOptions(scale, options)); + } } - throw new Error("unsupported legend type"); + 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)); + }; } -export function exposeLegends(type, options) { - return legend({...options, [type]: this.scale(type)}); +function legendOptions({label, ticks, tickFormat} = {}, options = {}) { + return {label, ticks, tickFormat, ...options}; } diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 216c24d51f..43796c831c 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,9 +1,10 @@ import {create} from "d3"; +import {maybeTickFormat} from "../axis.js"; import {applyInlineStyles, maybeClassName} from "../style.js"; export function legendSwatches(color, { columns, - format = x => x, + tickFormat, // TODO label, swatchSize = 15, swatchWidth = swatchSize, @@ -14,6 +15,7 @@ export function legendSwatches(color, { width } = {}) { className = maybeClassName(className); + tickFormat = maybeTickFormat(tickFormat, color.domain); const swatches = create("div") .attr("class", className) @@ -51,8 +53,8 @@ export function legendSwatches(color, { .style("--color", color.scale) .call(item => item.append("div") .attr("class", `${className}-label`) - .attr("title", format) - .text(format)); + .attr("title", tickFormat) + .text(tickFormat)); } else { extraStyle = ` .${className} { @@ -73,7 +75,7 @@ export function legendSwatches(color, { .join("span") .attr("class", `${className}-swatch`) .style("--color", color.scale) - .text(format); + .text(tickFormat); } return swatches diff --git a/src/plot.js b/src/plot.js index 7a48b4bfb9..8002f4fb9f 100644 --- a/src/plot.js +++ b/src/plot.js @@ -57,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"; @@ -115,7 +108,7 @@ export function plot(options = {}) { } figure.scale = exposeScales(scaleDescriptors); - figure.legend = exposeLegends; + figure.legend = exposeLegends(scaleDescriptors, options); return figure; } 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/plots/legend-color.js b/test/plots/legend-color.js index a7d926c363..fcc05cd286 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -74,6 +74,10 @@ export function colorLegendOrdinalSchemeRamp() { return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ", scheme: "rainbow"}}).legend("color", {legend: "ramp"}); } +export function colorLegendOrdinalTickFormat() { + return Plot.plot({color: {type: "ordinal", domain: [1, 2, 3, 4, 5], tickFormat: ".1f"}}).legend("color"); +} + export function colorLegendQuantitative() { return Plot.plot({color: {domain: [0, 10]}}).legend("color"); } @@ -179,14 +183,13 @@ export function colorLegendQuantileImplicit() { type: "quantile", scheme: "inferno", quantiles: 7, - label: "Inferno" + label: "Inferno", + tickFormat: ",d" }, marks: [ Plot.dot(d3.range(100), {fill: i => i ** 2}) ] - }).legend("color", { - tickFormat: ",d" - }); + }).legend("color"); } export function colorLegendDiverging() { From 8f02d27494d421032c4d03725737f6b28cd8b1c8 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 18:53:03 -0800 Subject: [PATCH 62/72] fix ordinal tickFormat function --- README.md | 13 ++++--- src/axis.js | 3 +- .../colorLegendOrdinalTickFormatFunction.html | 34 +++++++++++++++++++ test/plots/legend-color.js | 4 +++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 test/output/colorLegendOrdinalTickFormatFunction.html diff --git a/README.md b/README.md index b91f0a7c49..aae9a9460f 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ The scale object is undefined if the associated plot has no scale with the given ### Legends -Given a chart’s *color* scale, Plot can generate a legend: +Given a scale definition, Plot can generate a legend. #### *chart*.legend(*name*[, *options*]) @@ -234,17 +234,19 @@ Returns a suitable legend for the chart’s scale with the given *name*. For now 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*.**columns** - the number of swatches per row -* *options*.**format** - a format function for the labels +* *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*.**width** - the legend’s width * *options*.**height** - the legend’s height @@ -252,13 +254,10 @@ Continuous color legends are rendered as a ramp, and can be configured with the * *options*.**marginRight** - the legend’s right margin * *options*.**marginBottom** - the legend’s bottom margin * *options*.**marginLeft** - the legend’s left margin -* *options*.**ticks** - number of ticks -* *options*.**tickFormat** - a format function for the legend’s ticks -* *options*.**tickValues** - the legend’s tick values #### Plot.legend({[*name*]: *scale*, ...*options*}) -Builds a legend from a scale description object, passing the options described in the previous section. The only supported name for now is *color*. +Returns a legend for the given *scale* definition, passing the options described in the previous section. The only supported name for now is *color*. ### Position options diff --git a/src/axis.js b/src/axis.js index 1fb1206945..42a39d7686 100644 --- a/src/axis.js +++ b/src/axis.js @@ -207,12 +207,13 @@ function gridFacetY(index, fx, tx) { // 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 && typeof tickFormat !== "function") { + if (!scale.tickFormat) { tickFormat = maybeTickFormat(tickFormat, scale.domain()); } return axis(scale) 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 @@ +
+ 1.02.03.04.05.0 +
\ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index fcc05cd286..c73fb890c8 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -78,6 +78,10 @@ export function colorLegendOrdinalTickFormat() { return Plot.plot({color: {type: "ordinal", domain: [1, 2, 3, 4, 5], tickFormat: ".1f"}}).legend("color"); } +export function colorLegendOrdinalTickFormatFunction() { + return Plot.plot({color: {type: "ordinal", domain: [1, 2, 3, 4, 5], tickFormat: d => d.toFixed(1)}}).legend("color"); +} + export function colorLegendQuantitative() { return Plot.plot({color: {domain: [0, 10]}}).legend("color"); } From 81583bb20a451e642c62f9b8e9b9f08d85f3da1a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 19:02:47 -0800 Subject: [PATCH 63/72] explicit ordinal ticks --- src/legends/ramp.js | 7 +++-- test/output/colorLegendOrdinalTicks.svg | 34 +++++++++++++++++++++++++ test/plots/legend-color.js | 4 +++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 test/output/colorLegendOrdinalTicks.svg diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 860f16524a..870d5bc8c2 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -13,7 +13,6 @@ export function legendRamp(color, { style, ticks = width / 64, tickFormat, - tickValues, className }) { className = maybeClassName(className); @@ -105,7 +104,7 @@ export function legendRamp(color, { .attr("height", height - marginTop - marginBottom) .attr("fill", d => d); - tickValues = Array.from(thresholds, (_, i) => i); + ticks = Array.from(thresholds, (_, i) => i); tickFormat = i => thresholdFormat(thresholds[i], i); } @@ -131,10 +130,10 @@ export function legendRamp(color, { svg.append("g") .attr("transform", `translate(0,${height - marginBottom})`) .call(axisBottom(x) - .ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined) + .ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined) .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined) .tickSize(tickSize) - .tickValues(tickValues)) + .tickValues(Array.isArray(ticks) ? ticks : null)) .attr("font-size", null) .attr("font-family", null) .call(tickAdjust) diff --git a/test/output/colorLegendOrdinalTicks.svg b/test/output/colorLegendOrdinalTicks.svg new file mode 100644 index 0000000000..f454cfe500 --- /dev/null +++ b/test/output/colorLegendOrdinalTicks.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + 0 + + + 1 + + + 4 + + + \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index c73fb890c8..62a8082bc4 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -74,6 +74,10 @@ export function colorLegendOrdinalSchemeRamp() { return Plot.plot({color: {type: "ordinal", domain: "ABCDEFGHIJ", scheme: "rainbow"}}).legend("color", {legend: "ramp"}); } +export function colorLegendOrdinalTicks() { + return Plot.legend({color: {type: "categorical", domain: [0, 1, 2, 3, 4], ticks: [0, 1, 4]}, legend: "ramp"}); +} + export function colorLegendOrdinalTickFormat() { return Plot.plot({color: {type: "ordinal", domain: [1, 2, 3, 4, 5], tickFormat: ".1f"}}).legend("color"); } From d0c213e85ebcf4a783534c7d7bf48a15d15feba9 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 19:14:06 -0800 Subject: [PATCH 64/72] use pushState for tests --- test/plots/index.html | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test/plots/index.html b/test/plots/index.html index ca01213bca..4e7feea7b8 100644 --- a/test/plots/index.html +++ b/test/plots/index.html @@ -32,7 +32,11 @@ const select = document.createElement("select"); select.autofocus = true; select.style.margin = "1em 0"; -select.onchange = () => location.href = `?test=${select.value}`; +select.onchange = () => { + const {value} = select; + history.pushState({value}, "", `?test=${value}`); + render(); +}; select.append(...Object.keys(tests).map(key => { const option = document.createElement("option"); option.value = key; @@ -40,11 +44,25 @@ return option; })); +addEventListener("popstate", (event) => { + const {value} = history.state; + select.value = value; + render(); +}); + const initialValue = new URL(location).searchParams.get("test"); if (tests[initialValue]) select.value = initialValue; document.body.append(select); -Promise.resolve(tests[select.value]()).then(chart => document.body.append(chart)); +let currentChart; + +async function render() { + if (currentChart) currentChart.remove(); + currentChart = await Promise.resolve(tests[select.value]()); + document.body.append(currentChart); +} + +render(); From e458581e03c8c3c2624311bd57afc6640c6da965 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 19:29:34 -0800 Subject: [PATCH 65/72] round option --- README.md | 1 + src/legends/ramp.js | 21 +++++++++++++-------- test/output/colorLegendMargins.svg | 13 ++----------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index aae9a9460f..d5a58e8f35 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ Continuous color legends are rendered as a ramp, and can be configured with the * *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 diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 870d5bc8c2..3b807cf319 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -11,8 +11,9 @@ export function legendRamp(color, { marginBottom = 16 + tickSize, marginLeft = 0, style, - ticks = width / 64, + ticks = (width - marginLeft - marginRight) / 64, tickFormat, + round = true, className }) { className = maybeClassName(className); @@ -41,8 +42,15 @@ export function legendRamp(color, { .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 @@ -60,7 +68,8 @@ export function legendRamp(color, { // 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 = scale.copy().rangeRound( + x = applyRange( + scale.copy(), quantize( interpolateNumber(marginLeft, width - marginRight), Math.min( @@ -90,9 +99,7 @@ export function legendRamp(color, { // Construct a linear scale with evenly-spaced ticks for each of the // thresholds; the domain extends one beyond the threshold extent. - x = scaleLinear() - .domain([-1, range.length - 1]) - .rangeRound([marginLeft, width - marginRight]); + x = applyRange(scaleLinear().domain([-1, range.length - 1]), [marginLeft, width - marginRight]); svg.append("g") .selectAll("rect") @@ -110,9 +117,7 @@ export function legendRamp(color, { // Ordinal (hopefully!) else { - x = scaleBand() - .domain(domain) - .rangeRound([marginLeft, width - marginRight]); + x = applyRange(scaleBand().domain(domain), [marginLeft, width - marginRight]); svg.append("g") .selectAll("rect") diff --git a/test/output/colorLegendMargins.svg b/test/output/colorLegendMargins.svg index 99328b0b2f..919a0a928b 100644 --- a/test/output/colorLegendMargins.svg +++ b/test/output/colorLegendMargins.svg @@ -18,17 +18,8 @@ 0 - - 2 - - - 4 - - - 6 - - - 8 + + 5 10 From 740605fc00f10c016d9697e0ffd1192d2b281d1f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 24 Nov 2021 20:56:57 -0800 Subject: [PATCH 66/72] fix for truncated schemes --- src/legends/ramp.js | 7 ++-- .../colorLegendLinearTruncatedScheme.svg | 37 +++++++++++++++++++ test/plots/legend-color.js | 4 ++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 test/output/colorLegendLinearTruncatedScheme.svg diff --git a/src/legends/ramp.js b/src/legends/ramp.js index 3b807cf319..cd7555dce4 100644 --- a/src/legends/ramp.js +++ b/src/legends/ramp.js @@ -1,4 +1,5 @@ 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, { @@ -59,9 +60,9 @@ export function legendRamp(color, { // 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 = interpolate.length === 1 - ? interpolate - : piecewise(interpolate, 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 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 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + + + \ No newline at end of file diff --git a/test/plots/legend-color.js b/test/plots/legend-color.js index 62a8082bc4..29b89cb14e 100644 --- a/test/plots/legend-color.js +++ b/test/plots/legend-color.js @@ -98,6 +98,10 @@ export function colorLegendLinear() { return Plot.plot({color: {type: "linear", domain: [0, 10]}}).legend("color"); } +export function colorLegendLinearTruncatedScheme() { + return Plot.plot({color: {scheme: "rainbow", domain: [0, 1], range: [0.5, 1]}}).legend("color"); +} + export function colorLegendSqrt() { return Plot.plot({color: {type: "sqrt", domain: [0, 10]}}).legend("color"); } From 6db63fcdda2c376939d2bdd76f3353237d3dcc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 25 Nov 2021 22:12:26 +0100 Subject: [PATCH 67/72] opacity legend (#587) * opacity legend * add color option Co-authored-by: Mike Bostock --- README.md | 2 +- src/legends.js | 7 +++-- src/legends/opacity.js | 19 +++++++++++ src/mark.js | 9 ++++-- test/output/opacityLegend.svg | 37 ++++++++++++++++++++++ test/output/opacityLegendColor.svg | 37 ++++++++++++++++++++++ test/output/opacityLegendLinear.svg | 37 ++++++++++++++++++++++ test/output/opacityLegendLog.svg | 49 +++++++++++++++++++++++++++++ test/output/opacityLegendRange.svg | 37 ++++++++++++++++++++++ test/output/opacityLegendSqrt.svg | 37 ++++++++++++++++++++++ test/plots/index.js | 4 ++- test/plots/legend-opacity.js | 25 +++++++++++++++ 12 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 src/legends/opacity.js create mode 100644 test/output/opacityLegend.svg create mode 100644 test/output/opacityLegendColor.svg create mode 100644 test/output/opacityLegendLinear.svg create mode 100644 test/output/opacityLegendLog.svg create mode 100644 test/output/opacityLegendRange.svg create mode 100644 test/output/opacityLegendSqrt.svg create mode 100644 test/plots/legend-opacity.js diff --git a/README.md b/README.md index d5a58e8f35..f26e5fa1ed 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Continuous color legends are rendered as a ramp, and can be configured with the #### Plot.legend({[*name*]: *scale*, ...*options*}) -Returns a legend for the given *scale* definition, passing the options described in the previous section. The only supported name for now is *color*. +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 diff --git a/src/legends.js b/src/legends.js index d492ee5d58..5370475978 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,14 +1,17 @@ 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] + ["color", legendColor], + ["opacity", legendOpacity] ]); export function legend(options = {}) { for (const [key, value] of legendRegistry) { const scale = options[key]; - if (scale != null) { + if (isObject(scale)) { // e.g., ignore {color: "red"} return value(normalizeScale(key, scale), legendOptions(scale, options)); } } diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..dca39a523d --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,19 @@ +import {rgb} from "d3"; +import {legendColor} from "./color.js"; + +const black = rgb(0, 0, 0); + +export function legendOpacity({type, interpolate, ...scale}, { + legend = "ramp", + color = black, + ...options +}) { + if (!interpolate) throw new Error(`${type} opacity scales are not supported`); + 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/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/test/output/opacityLegend.svg b/test/output/opacityLegend.svg new file mode 100644 index 0000000000..3b26c8aae6 --- /dev/null +++ b/test/output/opacityLegend.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Quantitative + + \ No newline at end of file diff --git a/test/output/opacityLegendColor.svg b/test/output/opacityLegendColor.svg new file mode 100644 index 0000000000..e14b6d2c0a --- /dev/null +++ b/test/output/opacityLegendColor.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Linear + + \ No newline at end of file diff --git a/test/output/opacityLegendLinear.svg b/test/output/opacityLegendLinear.svg new file mode 100644 index 0000000000..48096cc4a2 --- /dev/null +++ b/test/output/opacityLegendLinear.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Linear + + \ No newline at end of file diff --git a/test/output/opacityLegendLog.svg b/test/output/opacityLegendLog.svg new file mode 100644 index 0000000000..268e96fe41 --- /dev/null +++ b/test/output/opacityLegendLog.svg @@ -0,0 +1,49 @@ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + + + + + + + + + + + + 10 + Log + + \ No newline at end of file diff --git a/test/output/opacityLegendRange.svg b/test/output/opacityLegendRange.svg new file mode 100644 index 0000000000..8be2684365 --- /dev/null +++ b/test/output/opacityLegendRange.svg @@ -0,0 +1,37 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Range + + \ No newline at end of file diff --git a/test/output/opacityLegendSqrt.svg b/test/output/opacityLegendSqrt.svg new file mode 100644 index 0000000000..0517968c76 --- /dev/null +++ b/test/output/opacityLegendSqrt.svg @@ -0,0 +1,37 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Sqrt + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 510e9b7957..ca859a8424 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -52,7 +52,6 @@ export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; -export * from "./legend-color.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; @@ -129,3 +128,6 @@ export {default as usRetailSales} from "./us-retail-sales.js"; export {default as usStatePopulationChange} from "./us-state-population-change.js"; export {default as wordCloud} from "./word-cloud.js"; export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; + +export * from "./legend-color.js"; +export * from "./legend-opacity.js"; diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js new file mode 100644 index 0000000000..0bcc76a208 --- /dev/null +++ b/test/plots/legend-opacity.js @@ -0,0 +1,25 @@ +import * as Plot from "@observablehq/plot"; + +export function opacityLegend() { + return Plot.legend({opacity: {domain: [0, 10], label: "Quantitative"}}); +} + +export function opacityLegendRange() { + return Plot.legend({opacity: {domain: [0, 1], range: [0.5, 1], label: "Range"}}); +} + +export function opacityLegendLinear() { + return Plot.legend({opacity: {type: "linear", domain: [0, 10], label: "Linear"}}); +} + +export function opacityLegendColor() { + return Plot.legend({opacity: {type: "linear", domain: [0, 10], label: "Linear"}, color: "steelblue"}); +} + +export function opacityLegendLog() { + return Plot.legend({opacity: {type: "log", domain: [1, 10], label: "Log"}}); +} + +export function opacityLegendSqrt() { + return Plot.legend({opacity: {type: "sqrt", domain: [0, 1], label: "Sqrt"}}); +} From 952fc089155d40ddf8acb620074ae6e9c852b057 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 25 Nov 2021 13:47:52 -0800 Subject: [PATCH 68/72] legend: true --- src/legends.js | 11 ++++++++ src/legends/color.js | 3 +- src/plot.js | 14 ++++++---- test/output/caltrain.html | 59 +++++++++++++++++++++++++++++++++++++++ test/output/caltrain.svg | 24 ---------------- test/plots/caltrain.js | 3 +- 6 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 test/output/caltrain.html delete mode 100644 test/output/caltrain.svg diff --git a/src/legends.js b/src/legends.js index 5370475978..8e3a564b5a 100644 --- a/src/legends.js +++ b/src/legends.js @@ -29,3 +29,14 @@ export function exposeLegends(scales, defaults = {}) { 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 index b3bb1d41fc..c9d87170a3 100644 --- a/src/legends/color.js +++ b/src/legends/color.js @@ -2,9 +2,10 @@ import {legendRamp} from "./ramp.js"; import {legendSwatches} from "./swatches.js"; export function legendColor(color, { - legend = color.type === "ordinal" ? "swatches" : "ramp", + 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); diff --git a/src/plot.js b/src/plot.js index 8002f4fb9f..4ceabf86aa 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,7 +1,7 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; -import {exposeLegends} from "./legends.js"; +import {Legends, exposeLegends} from "./legends.js"; import {markify} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {applyInlineStyles, filterStyles, maybeClassName, offset} from "./style.js"; @@ -100,11 +100,15 @@ 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); diff --git a/test/output/caltrain.html b/test/output/caltrain.html new file mode 100644 index 0000000000..1ab140e1c7 --- /dev/null +++ b/test/output/caltrain.html @@ -0,0 +1,59 @@ +
+
+ NLB +
+ + Northbound + Southbound + 5a8p9p10p11p8a9a5p6p10a11a12p1p2p3p4p6a7a7p12a + 010101010105050506061011111116161616162123232324242436363636384141414141415454 + 010101020303030303030312121218181821252525262626263636363838384449495151515757 + + + + + +
\ 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 @@ - - - Northbound - Southbound - 5a8p9p10p11p8a9a5p6p10a11a12p1p2p3p4p6a7a7p12a - 010101010105050506061011111116161616162123232324242436363636384141414141415454 - 010101020303030303030312121218181821252525262626263636363838384449495151515757 - - - - - \ No newline at end of file diff --git a/test/plots/caltrain.js b/test/plots/caltrain.js index 96ede50652..f24c23f2f6 100644 --- a/test/plots/caltrain.js +++ b/test/plots/caltrain.js @@ -11,7 +11,8 @@ export default async function() { }, color: { domain: "NLB", - range: ["currentColor", "peru", "brown"] + range: ["currentColor", "peru", "brown"], + legend: true }, marks: [ Plot.text([[1, "3"]], { From cc1d36e1322c1fbc97d27ac5642bd17cd1a41b9d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 25 Nov 2021 13:54:32 -0800 Subject: [PATCH 69/72] fix test determinism --- test/output/caltrain.html | 6 +++--- test/plot.js | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/output/caltrain.html b/test/output/caltrain.html index 1ab140e1c7..edb323cc21 100644 --- a/test/output/caltrain.html +++ b/test/output/caltrain.html @@ -32,9 +32,9 @@ margin-right: 1em; } NLB -
+
diff --git a/test/plot.js b/test/plot.js index 03a09d40a6..8b46345df6 100644 --- a/test/plot.js +++ b/test/plot.js @@ -9,19 +9,19 @@ for (const [name, plot] of Object.entries(plots)) { it(`plot ${name}`, async () => { const root = await plot(); const ext = root.tagName === "svg" ? "svg" : "html"; - const svg = root.tagName === "svg" ? root : root.querySelector("svg"); - if (svg) { + for (const svg of root.tagName === "svg" ? [root] : root.querySelectorAll("svg")) { svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); } - const style = root.querySelector("style"); - if (style) { + let index = 0; + for (const style of root.querySelectorAll("style")) { + const name = `plot${index++ ? `-${index}` : ""}`; const parent = style.parentNode; const uid = parent.getAttribute("class"); for (const child of [parent, ...parent.querySelectorAll("[class]")]) { - child.setAttribute("class", child.getAttribute("class").replace(new RegExp(`\\b${uid}\\b`, "g"), "plot")); + child.setAttribute("class", child.getAttribute("class").replace(new RegExp(`\\b${uid}\\b`, "g"), name)); } - style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), ".plot"); + style.textContent = style.textContent.replace(new RegExp(`[.]${uid}`, "g"), `.${name}`); } const actual = beautify.html(root.outerHTML, {indent_size: 2}); const outfile = path.resolve("./test/output", `${path.basename(name, ".js")}.${ext}`); From c748543dc01bf12b7e970bfa02be793056b037b2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 25 Nov 2021 13:56:55 -0800 Subject: [PATCH 70/72] fix inline opacity legends --- src/legends/opacity.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/legends/opacity.js b/src/legends/opacity.js index dca39a523d..7393c36289 100644 --- a/src/legends/opacity.js +++ b/src/legends/opacity.js @@ -4,11 +4,12 @@ import {legendColor} from "./color.js"; const black = rgb(0, 0, 0); export function legendOpacity({type, interpolate, ...scale}, { - legend = "ramp", + 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}); } From d3cd390d5f9df47a2b63faf7f9239cafe714abc2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 26 Nov 2021 10:39:41 -0800 Subject: [PATCH 71/72] arrow key navigation --- test/plots/index.html | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/test/plots/index.html b/test/plots/index.html index 4e7feea7b8..82096b53e9 100644 --- a/test/plots/index.html +++ b/test/plots/index.html @@ -37,6 +37,27 @@ history.pushState({value}, "", `?test=${value}`); render(); }; + +select.onkeydown = (event) => { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + switch (event.key) { + case "ArrowLeft": { + if (select.selectedIndex > 0) { + --select.selectedIndex; + select.onchange(); + } + break; + } + case "ArrowRight": { + if (select.selectedIndex < select.options.length - 1) { + ++select.selectedIndex; + select.onchange(); + } + break; + } + } +}; + select.append(...Object.keys(tests).map(key => { const option = document.createElement("option"); option.value = key; @@ -55,12 +76,12 @@ document.body.append(select); -let currentChart; +let currentChart = document.createElement("DIV"); async function render() { if (currentChart) currentChart.remove(); - currentChart = await Promise.resolve(tests[select.value]()); - document.body.append(currentChart); + const div = currentChart = document.body.appendChild(document.createElement("DIV")); + div.append(await tests[select.value]()); } render(); From 9fc55ab3af37be1981b2d52c8b07895ea5dbc3af Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 26 Nov 2021 11:04:20 -0800 Subject: [PATCH 72/72] ignore style if null --- src/style.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/style.js b/src/style.js index 0c65f5a634..8ea03c920c 100644 --- a/src/style.js +++ b/src/style.js @@ -166,7 +166,7 @@ export function maybeClassName(name) { export function applyInlineStyles(selection, style) { if (typeof style === "string") { selection.property("style", style); - } else { + } else if (style != null) { for (const element of selection) { Object.assign(element.style, style); }