diff --git a/README.md b/README.md index 74e5165cfe..44a885812b 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,45 @@ Plot.scale(plot1.scales.color) Returns a [D3 scale](https://github.com/d3/d3-scale) that matches the given Plot scale *options* object. +### Legends + +Plot will add a color legend to the figure if the *color*.*legend* option is given. + +* *color*.**legend** - a function that is passed the color options, and returns a DOM element to inserted at the top of the figure. If *color.legend* is true, defaults to Plot.legendColor. + +#### Plot.legendColor(*scaleOptions*) + +Generates a color legend, with swatches for categorical and ordinal scales, and a ramp for continuous scales. + +The color swatches can be configured with the following options: +* *color*.**columns** - the number of swatches per row +* *color*.**format** - a format function for the labels +* *color*.**swatchSize** - the size of the swatch (if square) +* *color*.**swatchWidth** - the swatches’ width +* *color*.**swatchHeight** - the swatches’ height +* *color*.**marginLeft** - the legend’s left margin + +The continuous color legends can be configured with the following options: +* *color*.**label** - the scale’s label +* *color*.**tickSize** - the tick size +* *color*.**width** - the legend’s width +* *color*.**height** - the legend’s height +* *color*.**marginTop** - the legend’s top margin +* *color*.**marginRight** - the legend’s right margin +* *color*.**marginBottom** - the legend’s bottom margin +* *color*.**marginLeft** - the legend’s left margin +* *color*.**ticks** - number of ticks +* *color*.**tickFormat** - a format function for the legend’s ticks +* *color*.**tickValues** - the legend’s tick values + +#### Plot.legendOpacity(*scaleOptions*) + +The default opacity legend—rendered as a grayscale color legend. Plot will add an opacity legend to the figure if the *opacity*.*legend* option is given. If *opacity*.*legend* is true, uses the default opacity legend; if *opacity*.*legend* is a function, it is called with the scale’s options and should return a DOM element. + +#### Plot.legendRadius(*scaleOptions*) + +The default radius legend—rendered as a circles on a common base. Plot will add a radius legend to the figure if the *r*.*legend* option is given. If *r*.*legend* is true, uses the default radius legend; if *r*.*legend* is a function, it is called with the scale’s options and should return a DOM element. + ### Position options The position scales (*x*, *y*, *fx*, and *fy*) support additional options: @@ -261,7 +300,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** (for *x* and *y* only; see [facet.grid](#facet-options)), **inset**, **round**, **align**, and **padding**. +Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **inset**, **round**, **align**, and **padding**. ### Color options diff --git a/package.json b/package.json index 985655809b..b3c9907833 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.2.1", + "canvas": "^2.8.0", "clean-css": "^5.1.1", "eslint": "^7.12.1", "esm": "^3.2.25", diff --git a/src/figure.js b/src/figure.js new file mode 100644 index 0000000000..d05c285d0c --- /dev/null +++ b/src/figure.js @@ -0,0 +1,24 @@ + +// Wrap the plot in a figure with a caption, if desired. +export function figureWrap(svg, {width}, caption, legends) { + if (caption == null && legends.length === 0) return svg; + const figure = document.createElement("figure"); + figure.style = `max-width: ${width}px`; + if (legends.length > 0) { + const figlegends = document.createElement("div"); + figlegends.className = "legends"; + figure.appendChild(figlegends); + for (const l of legends) { + if (l instanceof Node) { + figlegends.appendChild(l); + } + } + } + figure.appendChild(svg); + if (caption != null) { + 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 8c5ce92e40..cc800e1423 100644 --- a/src/index.js +++ b/src/index.js @@ -20,3 +20,5 @@ export {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 {legendColor} from "./legends/color.js"; +export {legendOpacity} from "./legends/opacity.js"; diff --git a/src/legends.js b/src/legends.js new file mode 100644 index 0000000000..3545e58982 --- /dev/null +++ b/src/legends.js @@ -0,0 +1,18 @@ +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 (let key in descriptors) { + let {legend, ...options} = descriptors[key]; + 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; +} diff --git a/src/legends/color.js b/src/legends/color.js new file mode 100644 index 0000000000..3743f146a8 --- /dev/null +++ b/src/legends/color.js @@ -0,0 +1,19 @@ +import {scale} from "../scales.js"; +import {legendRamp} from "./ramp.js"; +import {legendSwatches} from "./swatches.js"; + +export function legendColor(color, {width: maxWidth = 640} = {}) { + if (typeof color === "object" && "scales" in color) { + color = color.scales.color; + } + if (!color) return; + const {...options} = color; + switch (options.type) { + case "ordinal": case "categorical": + return legendSwatches(scale(options), options); + default: + options.key = "color"; // for diverging + if (options.width === undefined) options.width = Math.min(240, maxWidth); + return legendRamp(scale(options), options); + } +} diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..2986673c7b --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,13 @@ +import {legendColor} from "./color.js"; + +export function legendOpacity(plotOrScale, dimensions) { + if (!plotOrScale) return; + const opacity = "scales" in plotOrScale ? plotOrScale.scales.opacity : plotOrScale; + if (!opacity) return; + return legendColor({ + ...opacity, + range: undefined, + interpolate: undefined, + scheme: "greys" + }, dimensions); +} diff --git a/src/legends/radius.js b/src/legends/radius.js new file mode 100644 index 0000000000..53fdab3243 --- /dev/null +++ b/src/legends/radius.js @@ -0,0 +1,64 @@ +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, + ticks = 5, + tickFormat = (d) => d, + strokeWidth = 0.5, + strokeDasharray = [5, 4], + minStep = 8, + gap = 20, + ...r +}) { + 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..c4b13c5fa9 --- /dev/null +++ b/src/legends/ramp.js @@ -0,0 +1,153 @@ +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 +} = {}) { + 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..67452208d1 --- /dev/null +++ b/src/legends/swatches.js @@ -0,0 +1,46 @@ +import {create} from "d3"; + +export function legendSwatches(color, { + columns = null, + format = x => x, + swatchSize = 15, + swatchWidth = swatchSize, + swatchHeight = swatchSize, + marginLeft = 0, + width +} = {}) { + const swatches = create("div") + .classed("plot-swatches", true) + .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ + width === undefined ? "" : ` width: ${width}px;` + }`); + + 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 3e6e70c26c..76a3c400ae 100644 --- a/src/plot.js +++ b/src/plot.js @@ -3,6 +3,8 @@ import {Axes, autoAxisTicks, autoAxisLabels, autoScaleLabel} from "./axes.js"; import {facets} from "./facet.js"; import {values} from "./mark.js"; import {Scales, autoScaleRange, exposeScales} from "./scales.js"; +import {figureWrap} from "./figure.js"; +import {createLegends} from "./legends.js"; import {offset} from "./style.js"; export function plot(options = {}) { @@ -91,7 +93,11 @@ export function plot(options = {}) { if (node != null) svg.appendChild(node); } - return exposeScales(wrap(svg, {caption}), scaleDescriptors); + const descriptors = exposeScales(scaleDescriptors); + const legends = createLegends(descriptors, dimensions); + const figure = figureWrap(svg, dimensions, caption, legends); + figure.scales = descriptors; + return figure; } function Dimensions( @@ -140,14 +146,3 @@ function autoHeight({y, fy, fx}) { const ny = y ? (y.family === "ordinal" ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1; return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60; } - -// Wrap the plot in a figure with a caption, if desired. -function wrap(svg, {caption} = {}) { - if (caption == null) return svg; - const figure = document.createElement("figure"); - 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/scales.js b/src/scales.js index 5c0343f9ce..7fb62f75f3 100644 --- a/src/scales.js +++ b/src/scales.js @@ -78,7 +78,7 @@ function Scale(key, channels = [], options = {}) { } export function scale(options) { - return Scale(undefined, undefined, options).scale; + return Scale(options.key, undefined, options).scale; } function inferScaleType(key, channels, {type, domain, range}) { @@ -112,8 +112,8 @@ function asOrdinalType(key, type = "categorical") { return registry.get(key) === position ? "point" : type; } -export function exposeScales(figure, scaleDescriptors) { - const scales = figure.scales = {}; +export function exposeScales(scaleDescriptors) { + const scales = {}; for (const key in scaleDescriptors) { let cache; Object.defineProperty(scales, key, { @@ -121,7 +121,7 @@ export function exposeScales(figure, scaleDescriptors) { get: () => cache = cache || exposeScale(scaleDescriptors[key]) }); } - return figure; + return scales; } function exposeScale({scale, ...options}) { @@ -130,6 +130,7 @@ function exposeScale({scale, ...options}) { domain: scale.domain(), range: scale.range(), ...scale.interpolate && {interpolate: scale.interpolate()}, + ...scale.interpolator && {interpolate: scale.interpolator(), range: undefined}, ...scale.clamp && {clamp: scale.clamp()}, ...options }; diff --git a/src/style.css b/src/style.css index 3e3f60c161..41320c4de1 100644 --- a/src/style.css +++ b/src/style.css @@ -10,3 +10,51 @@ .plot text { white-space: pre; } + +.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); +} + diff --git a/test/output/athletesHeightWeightBin.html b/test/output/athletesHeightWeightBin.html new file mode 100644 index 0000000000..a8cc6da74f --- /dev/null +++ b/test/output/athletesHeightWeightBin.html @@ -0,0 +1,955 @@ +
+
+ + + + 0 + + + 20 + + + 40 + + + 60 + + + 80 + + + 100 + Frequency + +
+ + + + 1.2 + + + + 1.3 + + + + 1.4 + + + + 1.5 + + + + 1.6 + + + + 1.7 + + + + 1.8 + + + + 1.9 + + + + 2.0 + + + + 2.1 + + + + 2.2 + ↑ height + + + + + 40 + + + + 60 + + + + 80 + + + + 100 + + + + 120 + + + + 140 + + + + 160 + weight → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/athletesHeightWeightBin.svg b/test/output/athletesHeightWeightBin.svg deleted file mode 100644 index af6b696d38..0000000000 --- a/test/output/athletesHeightWeightBin.svg +++ /dev/null @@ -1,931 +0,0 @@ - - - - - 1.2 - - - - 1.3 - - - - 1.4 - - - - 1.5 - - - - 1.6 - - - - 1.7 - - - - 1.8 - - - - 1.9 - - - - 2.0 - - - - 2.1 - - - - 2.2 - ↑ height - - - - - 40 - - - - 60 - - - - 80 - - - - 100 - - - - 120 - - - - 140 - - - - 160 - weight → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/figcaption.html b/test/output/figcaption.html index 822194ec6f..6ce63087e8 100644 --- a/test/output/figcaption.html +++ b/test/output/figcaption.html @@ -1,4 +1,4 @@ -
+
diff --git a/test/output/figcaptionHtml.html b/test/output/figcaptionHtml.html index f0c8da2b4b..e833a7a241 100644 --- a/test/output/figcaptionHtml.html +++ b/test/output/figcaptionHtml.html @@ -1,4 +1,4 @@ -
+
diff --git a/test/output/industryUnemploymentShare.html b/test/output/industryUnemploymentShare.html new file mode 100644 index 0000000000..489cd05c33 --- /dev/null +++ b/test/output/industryUnemploymentShare.html @@ -0,0 +1,193 @@ +
+
+
+
+
+
+
Agriculture
+
+
+
+
Business services
+
+
+
+
Construction
+
+
+
+
Education and Health
+
+
+
+
Finance
+
+
+
+
Government
+
+
+
+
Information
+
+
+
+
Leisure and hospitality
+
+
+
+
Manufacturing
+
+
+
+
Mining and Extraction
+
+
+
+
Other
+
+
+
+
Self-employed
+
+
+
+
Transportation and Utilities
+
+
+
+
Wholesale and Retail Trade
+
+
+
+
+ + + + 0% + + + + 10% + + + + 20% + + + + 30% + + + + 40% + + + + 50% + + + + 60% + + + + 70% + + + + 80% + + + + 90% + + + + 100% + ↑ unemployed + + + + 2000 + + + 2001 + + + 2002 + + + 2003 + + + 2004 + + + 2005 + + + 2006 + + + 2007 + + + 2008 + + + 2009 + + + 2010 + + + + + Wholesale and Retail Trade + + + Manufacturing + + + Leisure and hospitality + + + Business services + + + Construction + + + Education and Health + + + Government + + + Finance + + + Self-employed + + + Other + + + Transportation and Utilities + + + Information + + + Agriculture + + + Mining and Extraction + + + + + + +
\ No newline at end of file diff --git a/test/output/industryUnemploymentShare.svg b/test/output/industryUnemploymentShare.svg deleted file mode 100644 index 36c8143875..0000000000 --- a/test/output/industryUnemploymentShare.svg +++ /dev/null @@ -1,130 +0,0 @@ - - - - - 0% - - - - 10% - - - - 20% - - - - 30% - - - - 40% - - - - 50% - - - - 60% - - - - 70% - - - - 80% - - - - 90% - - - - 100% - ↑ unemployed - - - - 2000 - - - 2001 - - - 2002 - - - 2003 - - - 2004 - - - 2005 - - - 2006 - - - 2007 - - - 2008 - - - 2009 - - - 2010 - - - - - Wholesale and Retail Trade - - - Manufacturing - - - Leisure and hospitality - - - Business services - - - Construction - - - Education and Health - - - Government - - - Finance - - - Self-employed - - - Other - - - Transportation and Utilities - - - Information - - - Agriculture - - - Mining and Extraction - - - - - - \ No newline at end of file diff --git a/test/output/penguinSpeciesIsland.html b/test/output/penguinSpeciesIsland.html new file mode 100644 index 0000000000..522226d636 --- /dev/null +++ b/test/output/penguinSpeciesIsland.html @@ -0,0 +1,61 @@ +
+
+
BiscoeDreamTorgersen
+
+ + + + 0 + + + + 20 + + + + 40 + + + + 60 + + + + 80 + + + + 100 + + + + 120 + + + + 140 + ↑ Frequency + + + + Adelie + + + Chinstrap + + + Gentoo + species + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/penguinSpeciesIsland.svg b/test/output/penguinSpeciesIsland.svg deleted file mode 100644 index cc449d8f7d..0000000000 --- a/test/output/penguinSpeciesIsland.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - 0 - - - - 20 - - - - 40 - - - - 60 - - - - 80 - - - - 100 - - - - 120 - - - - 140 - ↑ Frequency - - - - Adelie - - - Chinstrap - - - Gentoo - species - - - - - - - - - - - - \ No newline at end of file diff --git a/test/plot.js b/test/plot.js index 6fdc9435fc..89cef7bfdb 100644 --- a/test/plot.js +++ b/test/plot.js @@ -13,6 +13,8 @@ import * as plots from "./plots/index.js"; const {window} = new JSDOM(""); global.document = window.document; global.Node = window.Node; + global.NodeList = window.NodeList; + global.HTMLCollection = window.HTMLCollection; // Not fully functional, but only used to fetch data files, so should be fine? global.fetch = async (href) => new Response(path.resolve("./test", href)); @@ -41,6 +43,8 @@ import * as plots from "./plots/index.js"; } finally { delete global.document; delete global.Node; + delete global.NodeList; + delete global.HTMLCollection; delete global.fetch; } }); diff --git a/test/plots/athletes-height-weight-bin.js b/test/plots/athletes-height-weight-bin.js index ef8e90ca1a..7c06bdc382 100644 --- a/test/plots/athletes-height-weight-bin.js +++ b/test/plots/athletes-height-weight-bin.js @@ -11,7 +11,9 @@ export default async function() { ticks: 10 }, color: { - scheme: "YlGnBu" + scheme: "YlGnBu", + legend: true, + nice: true }, marks: [ Plot.rect(athletes, Plot.bin({fill: "count"}, {x: "weight", y: "height", thresholds: 50})) diff --git a/test/plots/industry-unemployment-share.js b/test/plots/industry-unemployment-share.js index cc42e17573..09c9f2f886 100644 --- a/test/plots/industry-unemployment-share.js +++ b/test/plots/industry-unemployment-share.js @@ -8,6 +8,10 @@ export default async function() { grid: true, tickFormat: "%" }, + color: { + legend: true, + columns: 5 + }, marks: [ Plot.areaY(data, Plot.stackY({ x: "date", diff --git a/test/plots/penguin-species-island.js b/test/plots/penguin-species-island.js index a5ef2b2324..4c145e1861 100644 --- a/test/plots/penguin-species-island.js +++ b/test/plots/penguin-species-island.js @@ -7,6 +7,9 @@ export default async function() { y: { grid: true }, + color: { + legend: true + }, marks: [ Plot.barY(data, Plot.groupX({y: "count"}, {x: "species", fill: "island"})), Plot.ruleY([0]) diff --git a/test/scales/legend-test.js b/test/scales/legend-test.js new file mode 100644 index 0000000000..a1b56510a6 --- /dev/null +++ b/test/scales/legend-test.js @@ -0,0 +1,26 @@ +import * as Plot from "@observablehq/plot"; +import tape from "tape-await"; +import {JSDOM} from "jsdom"; +const {window} = new JSDOM(""); +global.document = window.document; +global.Node = window.Node; + +tape("legendColor shows swatches", test => { + const A = Plot.legendColor({ type: "diverging", domain: [-1, 1] }); + test.equal(A.outerHTML, `−1.0−0.50.00.51.0`); +}); + +tape("legendColor accepts a scale definition", test => { + const color = {}; + test.assert(Plot.legendColor(color).outerHTML.match(/^ { + const plot = Plot.cellX([1,2]).plot(); + test.assert(Plot.legendColor(plot).outerHTML.match(/^ { + const plot = Plot.dotX([1,2]).plot(); + test.equal(Plot.legendColor(plot), undefined); +}); diff --git a/yarn.lock b/yarn.lock index 92760e65a4..54fc8f8fe6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,6 +45,21 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@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/git@^2.0.1": version "2.0.8" resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.8.tgz#c38b54cdeec556ab641cf6161cc7825711a88d65" @@ -409,6 +424,15 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +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" @@ -826,6 +850,13 @@ decimal.js@^10.2.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3" integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== +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" + deep-equal@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" @@ -889,6 +920,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= + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" @@ -1818,6 +1854,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +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== + dependencies: + semver "^6.0.0" + make-fetch-happen@^8.0.9: version "8.0.14" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz#aaba73ae0ab5586ad8eaa68bd83332669393e222" @@ -1856,6 +1899,11 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.47.0" +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== + minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -1950,11 +1998,21 @@ 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== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +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@^7.1.0: version "7.1.2" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-7.1.2.tgz#21a810aebb187120251c3bcec979af1587b188ae" @@ -2102,7 +2160,7 @@ object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" -once@^1.3.0: +once@^1.3.0, once@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -2466,6 +2524,11 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: version "7.3.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" @@ -2507,6 +2570,20 @@ signal-exit@^3.0.0: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== +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" + slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"