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