diff --git a/README.md b/README.md index 33ce1d1d28..c4982bcfd6 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,70 @@ Plot.plot({ }) ``` +All the scale definitions are exposed through the *scales* property of the plot. + +```js +color = Plot.plot({…}).scales("color"); +color.range // ["red", "blue"] +``` + +And, to reuse the scale in another plot: + +```js +const plot1 = Plot.plot(…); + +Plot.plot({ + color: plot1.scales("color") +}) +``` + +#### Plot.scale(*scaleOptions*) + +```js +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: @@ -237,7 +301,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 b9c938ff74..52bd582487 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.0.4", + "canvas": "^2.8.0", "clean-css": "^5.1.1", "eslint": "^7.12.1", "htl": "^0.3.0", diff --git a/src/axes.js b/src/axes.js index cea4ad3c06..e5fe1be77a 100644 --- a/src/axes.js +++ b/src/axes.js @@ -36,7 +36,7 @@ function autoAxisTicksK(scale, axis, k) { } } -// Mutates axis.{label,labelAnchor,labelOffset}! +// Mutates axis.{label,labelAnchor,labelOffset} and scale.label! export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) { if (fx) { autoAxisLabelsX(fx, scales.fx, channels.get("fx")); @@ -70,24 +70,34 @@ export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) { function autoAxisLabelsX(axis, scale, channels) { if (axis.labelAnchor === undefined) { - axis.labelAnchor = scale.type === "ordinal" ? "center" + axis.labelAnchor = scale.family === "ordinal" ? "center" : scale.reverse ? "left" : "right"; } if (axis.label === undefined) { axis.label = inferLabel(channels, scale, axis, "x"); } + scale.label = axis.label; } function autoAxisLabelsY(axis, opposite, scale, channels) { if (axis.labelAnchor === undefined) { - axis.labelAnchor = scale.type === "ordinal" ? "center" + axis.labelAnchor = scale.family === "ordinal" ? "center" : opposite && opposite.axis === "top" ? "bottom" // TODO scale.reverse? : "top"; } if (axis.label === undefined) { axis.label = inferLabel(channels, scale, axis, "y"); } + scale.label = axis.label; +} + +export function autoScaleLabel(scale, channels, options) { + if (scale === undefined) return; + if (options !== undefined) scale.label = options.label; + if (scale.label === undefined) { + scale.label = inferLabel(channels, scale, {}); + } } // Channels can have labels; if all the channels for a given scale are @@ -104,8 +114,8 @@ function inferLabel(channels = [], scale, axis, key) { if (candidate !== undefined) { const {percent, reverse} = scale; // Ignore the implicit label for temporal scales if it’s simply “date”. - if (scale.type === "temporal" && /^(date|time|year)$/i.test(candidate)) return; - if (scale.type !== "ordinal" && (key === "x" || key === "y")) { + if (scale.family === "temporal" && /^(date|time|year)$/i.test(candidate)) return; + if (scale.family !== "ordinal" && (key === "x" || key === "y")) { if (percent) candidate = `${candidate} (%)`; if (axis.labelAnchor === "center") { candidate = `${candidate} →`; 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 6a8f601e44..268a7f4e8e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ export {plot} from "./plot.js"; +export {scale} from "./scales.js"; export {Mark, marks, valueof} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; @@ -22,3 +23,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..08603e0c19 --- /dev/null +++ b/src/legends.js @@ -0,0 +1,21 @@ +import {registry} from "./scales/index.js"; +import {legendColor} from "./legends/color.js"; +import {legendOpacity} from "./legends/opacity.js"; +import {legendRadius} from "./legends/radius.js"; + +export function createLegends(descriptors, dimensions) { + const legends = []; + for (const [key] of registry) { + const scale = descriptors(key); + if (scale === undefined) continue; + let {legend, ...options} = scale; + if (key === "color" && legend === true) legend = legendColor; + if (key === "opacity" && legend === true) legend = legendOpacity; + if (key === "r" && legend === true) legend = legendRadius; + if (typeof legend === "function") { + const l = legend(options, dimensions); + if (l instanceof Node) legends.push(l); + } + } + return legends; +} diff --git a/src/legends/color.js b/src/legends/color.js new file mode 100644 index 0000000000..0e105b83b3 --- /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 && typeof color.scales === "function") { + 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 e2d526853f..83b3902818 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,8 +1,11 @@ import {create} from "d3"; -import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js"; +import {Axes, autoAxisTicks, autoAxisLabels, autoScaleLabel} from "./axes.js"; import {facets} from "./facet.js"; +import {figureWrap} from "./figure.js"; +import {createLegends} from "./legends.js"; import {markify} from "./mark.js"; -import {Scales, autoScaleRange, applyScales} from "./scales.js"; +import {Scales, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {registry, position} from "./scales/index.js"; import {filterStyles, offset} from "./style.js"; export function plot(options = {}) { @@ -52,6 +55,9 @@ export function plot(options = {}) { autoScaleRange(scaleDescriptors, dimensions); autoAxisTicks(scaleDescriptors, axes); autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions); + for (const [key, type] of registry) { + if (type !== position) autoScaleLabel(scaleDescriptors[key], scaleChannels.get(key), options[key]); + } // Normalize the options. options = {...scaleDescriptors, ...dimensions}; @@ -90,11 +96,10 @@ export function plot(options = {}) { } // Wrap the plot in a figure with a caption, if desired. - if (caption == null) return svg; - const figure = document.createElement("figure"); - figure.appendChild(svg); - const figcaption = figure.appendChild(document.createElement("figcaption")); - figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption)); + const descriptors = exposeScales(scaleDescriptors); + const legends = createLegends(descriptors, dimensions); + const figure = figureWrap(svg, dimensions, caption, legends); + figure.scales = exposeScales(scaleDescriptors); return figure; } @@ -141,6 +146,6 @@ function ScaleFunctions(scales) { function autoHeight({y, fy, fx}) { const nfy = fy ? fy.scale.domain().length : 1; - const ny = y ? (y.type === "ordinal" ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1; + 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; } diff --git a/src/scales.js b/src/scales.js index ba75e184a6..77b030c313 100644 --- a/src/scales.js +++ b/src/scales.js @@ -46,20 +46,21 @@ function autoScaleRangeY(scale, dimensions) { const {inset = 0} = scale; const {height, marginTop = 0, marginBottom = 0} = dimensions; const range = [height - marginBottom - inset, marginTop + inset]; - if (scale.type === "ordinal") range.reverse(); + if (scale.family === "ordinal") range.reverse(); scale.scale.range(range); } autoScaleRound(scale); } function autoScaleRound(scale) { - if (scale.round === undefined && scale.type === "ordinal" && scale.scale.step() >= 5) { + if (scale.round === undefined && scale.family === "ordinal" && scale.scale.step() >= 5) { scale.scale.round(true); } } function Scale(key, channels = [], options = {}) { const type = inferScaleType(key, channels, options); + options.type = type; // Once the scale type is known, coerce the associated channel values and any // explicitly-specified domain to the expected type. @@ -113,6 +114,10 @@ function Scale(key, channels = [], options = {}) { } } +export function scale(options) { + return Scale(options.key, undefined, options).scale; +} + function inferScaleType(key, channels, {type, domain, range}) { if (key === "fx" || key === "fy") return "band"; if (type !== undefined) { @@ -128,7 +133,7 @@ function inferScaleType(key, channels, {type, domain, range}) { for (const {type} of channels) if (type !== undefined) return type; if ((domain || range || []).length > 2) return asOrdinalType(key); if (domain !== undefined) { - if (isOrdinal(domain)) return asOrdinalType(key); + if (isOrdinal(domain)) return asOrdinalType(key, type); if (isTemporal(domain)) return "utc"; return "linear"; } @@ -140,8 +145,8 @@ function inferScaleType(key, channels, {type, domain, range}) { } // Positional scales default to a point scale instead of an ordinal scale. -function asOrdinalType(key) { - return registry.get(key) === position ? "point" : "ordinal"; +function asOrdinalType(key, type = "categorical") { + return registry.get(key) === position ? "point" : type; } // TODO use Float64Array.from for position and radius scales? @@ -205,3 +210,24 @@ function coerceDate(x) { : x == null || isNaN(x = +x) ? undefined : new Date(x); } + +// prepare scales for exposure through the plot's scales() function +export function exposeScales(scaleDescriptors) { + return function(key) { + if (registry.has(key)) + return key in scaleDescriptors ? exposeScale(scaleDescriptors[key]) : undefined; + throw new Error(`no such scale ${key}`); + }; +} + +function exposeScale({scale, ...options}) { + for (const remove of ["domain", "range", "interpolate", "clamp", "round", "nice", "padding", "inset", "reverse", "family"]) delete options[remove]; + return { + 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/scales/ordinal.js b/src/scales/ordinal.js index da214c27e1..d08fea7c91 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -8,7 +8,7 @@ export function ScaleO(scale, channels, { domain = inferDomain(channels), range, reverse, - inset + ...options }) { if (reverse = !!reverse) domain = reverseof(domain); scale.domain(domain); @@ -17,7 +17,7 @@ export function ScaleO(scale, channels, { if (typeof range === "function") range = range(domain); scale.range(range); } - return {type: "ordinal", reverse, domain, range, scale, inset}; + return {family: "ordinal", domain, range, scale, ...options}; } export function ScaleOrdinal(key, channels, { @@ -26,7 +26,7 @@ export function ScaleOrdinal(key, channels, { range = registry.get(key) === color ? ordinalScheme(scheme) : undefined, ...options }) { - return ScaleO(scaleOrdinal().unknown(undefined), channels, {range, ...options}); + return ScaleO(scaleOrdinal().unknown(undefined), channels, {range, type, ...options}); } export function ScalePoint(key, channels, { @@ -39,7 +39,7 @@ export function ScalePoint(key, channels, { .align(align) .padding(padding), channels, - options + {align, padding, ...options} ); } @@ -56,12 +56,11 @@ export function ScaleBand(key, channels, { .paddingInner(paddingInner) .paddingOuter(paddingOuter), channels, - options + {align, paddingInner, paddingOuter, ...options} ); } -function maybeRound(scale, channels, options = {}) { - const {round} = options; +function maybeRound(scale, channels, {round, ...options} = {}) { if (round !== undefined) scale.round(round); scale = ScaleO(scale, channels, options); scale.round = round; diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 4bf6ec0a7b..0fb89b0922 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -49,14 +49,13 @@ export function ScaleQ(key, scale, channels, { clamp, zero, domain = (registry.get(key) === radius || registry.get(key) === opacity ? inferZeroDomain : inferDomain)(channels), - percent, round, range = registry.get(key) === radius ? inferRadialRange(channels, domain) : registry.get(key) === opacity ? [0, 1] : undefined, type, scheme = type === "cyclical" ? "rainbow" : "turbo", interpolate = registry.get(key) === color ? (range !== undefined ? interpolateRgb : quantitativeScheme(scheme)) : round ? interpolateRound : undefined, reverse, - inset + ...rest }) { if (zero) domain = domain[1] < 0 ? [domain[0], 0] : domain[0] > 0 ? [0, domain[1]] : domain; if (reverse = !!reverse) domain = reverseof(domain); @@ -80,7 +79,7 @@ export function ScaleQ(key, scale, channels, { if (range !== undefined) scale.range(range); if (clamp) scale.clamp(clamp); - return {type: "quantitative", reverse, domain, range, scale, inset, percent}; + return {family: "quantitative", reverse, domain, range, scale, type, ...rest}; } export function ScaleLinear(key, channels, options) { @@ -92,11 +91,11 @@ export function ScaleSqrt(key, channels, options) { } export function ScalePow(key, channels, {exponent = 1, ...options}) { - return ScaleQ(key, scalePow().exponent(exponent), channels, options); + return ScaleQ(key, scalePow().exponent(exponent), channels, options.type === "sqrt" ? options : {exponent, ...options}); } export function ScaleLog(key, channels, {base = 10, domain = inferLogDomain(channels), ...options}) { - return ScaleQ(key, scaleLog().base(base), channels, {domain, ...options}); + return ScaleQ(key, scaleLog().base(base), channels, {base, domain, ...options}); } export function ScaleQuantile(key, channels, { @@ -112,7 +111,7 @@ export function ScaleQuantile(key, channels, { } export function ScaleSymlog(key, channels, {constant = 1, ...options}) { - return ScaleQ(key, scaleSymlog().constant(constant), channels, options); + return ScaleQ(key, scaleSymlog().constant(constant), channels, {constant, ...options}); } export function ScaleThreshold(key, channels, { @@ -128,7 +127,7 @@ export function ScaleThreshold(key, channels, { } export function ScaleIdentity() { - return {type: "identity", scale: scaleIdentity()}; + return {family: "identity", scale: scaleIdentity(), type: "identity"}; } export function inferDomain(channels, f) { diff --git a/src/scales/temporal.js b/src/scales/temporal.js index 71034b2221..165d8108b8 100644 --- a/src/scales/temporal.js +++ b/src/scales/temporal.js @@ -3,7 +3,7 @@ import {ScaleQ} from "./quantitative.js"; function ScaleT(key, scale, channels, options) { const s = ScaleQ(key, scale, channels, options); - s.type = "temporal"; + s.family = "temporal"; return s; } diff --git a/src/style.css b/src/style.css index 3e3f60c161..fc9d24a2aa 100644 --- a/src/style.css +++ b/src/style.css @@ -10,3 +10,50 @@ .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 index af6b696d38..931b27bbd1 100644 --- a/test/output/athletesHeightWeightBin.svg +++ b/test/output/athletesHeightWeightBin.svg @@ -76,856 +76,856 @@ weight → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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/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/plot.js b/test/plot.js index 7c0146ae6b..ecea7de0a5 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)); @@ -58,6 +60,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 96feae2271..baec777af7 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"})) 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..ddabf903d4 --- /dev/null +++ b/test/scales/legend-test.js @@ -0,0 +1,26 @@ +import * as Plot from "@observablehq/plot"; +import assert from "assert"; +import {JSDOM} from "jsdom"; +const {window} = new JSDOM(""); +global.document = window.document; +global.Node = window.Node; + +it("legendColor shows swatches", () => { + const A = Plot.legendColor({ type: "diverging", domain: [-1, 1] }); + assert.strictEqual(A.outerHTML, `−1.0−0.50.00.51.0`); +}); + +it("legendColor accepts a scale definition", () => { + const color = {}; + assert(Plot.legendColor(color).outerHTML.match(/^ { + const plot = Plot.cellX([1,2]).plot(); + assert(Plot.legendColor(plot).outerHTML.match(/^ { + const plot = Plot.dotX([1,2]).plot(); + assert.strictEqual(Plot.legendColor(plot), undefined); +}); diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js new file mode 100644 index 0000000000..9d5b19f5f5 --- /dev/null +++ b/test/scales/scales-test.js @@ -0,0 +1,203 @@ +import * as Plot from "@observablehq/plot"; +import assert from "assert"; +import {JSDOM} from "jsdom"; +const {window} = new JSDOM(""); +global.document = window.document; + +it("plot(…).scales exposes the plot’s scales", () => { + const plot = Plot.dot([1, 2], {x: d => d, y: d => d}).plot(); + const scales = plot.scales; + assert.strictEqual(typeof scales, "function"); + assert(scales("x")); + assert(scales("y")); + assert.strictEqual(scales("r"), undefined); +}); + +it("plot(…).scales(z) throws an error if z ins not a registered scale", () => { + const plot = Plot.dot([1, 2], {x: d => d, y: d => d}).plot(); + const scales = plot.scales; + assert.throws(() => scales("z")); +}); + +it("plot(…).scales('x') exposes the plot’s x scale", () => { + const x = Plot.dot([1, 2], {x: d => d}).plot().scales("x"); + assert.deepStrictEqual(x.domain, [1, 2]); + assert.deepStrictEqual(x.range, [20, 620]); + assert.strictEqual(typeof x.interpolate, "function"); + assert.strictEqual(x.type, "linear"); + assert.strictEqual(x.clamp, false); + assert.strictEqual(typeof Plot.scale(x), "function"); +}); + +it("plot(…).scales('y') exposes the plot’s y scale", () => { + const y0 = Plot.dot([1, 2], {x: d => d}).plot().scales("y"); + assert.strictEqual(y0, undefined); + const y = Plot.dot([1, 2], {y: d => d}).plot().scales("y"); + assert.deepStrictEqual(y.domain, [1, 2]); + assert.deepStrictEqual(y.range, [380, 20]); + assert.strictEqual(typeof y.interpolate, "function"); + assert.strictEqual(y.type, "linear"); + assert.strictEqual(y.clamp, false); + assert.strictEqual(typeof Plot.scale(y), "function"); +}); + +it("plot(…).scales('fx') exposes the plot’s fx scale", () => { + const fx0 = Plot.dot([1, 2], {x: d => d}).plot().scales("fx"); + assert.strictEqual(fx0, undefined); + const data = [1, 2]; + const fx = Plot.dot(data, {y: d => d}).plot({facet: {data, x: data}}).scales("fx"); + assert.deepStrictEqual(fx.domain, [1, 2]); + assert.deepStrictEqual(fx.range, [40, 620]); + assert.strictEqual(typeof fx.interpolate, "undefined"); + assert.strictEqual(fx.type, "band"); + assert.strictEqual(fx.clamp, undefined); + assert.strictEqual(typeof Plot.scale(fx), "function"); +}); + +it("plot(…).scales('fy') exposes the plot’s fy scale", () => { + const fy0 = Plot.dot([1, 2], {x: d => d}).plot().scales("fy"); + assert.strictEqual(fy0, undefined); + const data = [1, 2]; + const fy = Plot.dot(data, {y: d => d}).plot({facet: {data, y: data}}).scales("fy"); + assert.deepStrictEqual(fy.domain, [1, 2]); + assert.deepStrictEqual(fy.range, [20, 380]); + assert.strictEqual(typeof fy.interpolate, "undefined"); + assert.strictEqual(fy.type, "band"); + assert.strictEqual(fy.clamp, undefined); + assert.strictEqual(typeof Plot.scale(fy), "function"); +}); + +it("plot(…).scales('color') exposes a continuous color scale", () => { + const color0 = Plot.dot([1, 2], {x: d => d}).plot().scales("color"); + assert.strictEqual(color0, undefined); + const data = [1, 2, 3, 4, 5]; + const color = Plot.dot(data, {y: d => d, fill: d => d}).plot().scales("color"); + assert.deepStrictEqual(color.domain, [1, 5]); + assert.deepStrictEqual(color.range, [0, 1]); + assert.strictEqual(typeof color.interpolate, "function"); + assert.strictEqual(color.type, "linear"); + assert.strictEqual(color.clamp, false); + assert.strictEqual(typeof Plot.scale(color), "function"); +}); + +it("plot(…).scales('color') exposes an ordinal color scale", () => { + const data = ["a", "b", "c", "d"]; + const color = Plot.dot(data, {y: d => d, fill: d => d}).plot({ color: { type: "ordinal" }}).scales("color"); + assert.deepStrictEqual(color.domain, data); + assert.deepStrictEqual(color.range, ['rgb(35, 23, 27)', 'rgb(46, 229, 174)', 'rgb(254, 185, 39)', 'rgb(144, 12, 0)']); + assert.strictEqual(typeof color.interpolate, "undefined"); + assert.strictEqual(color.type, "ordinal"); + assert.strictEqual(color.clamp, undefined); + assert.strictEqual(typeof Plot.scale(color), "function"); +}); + +it("plot(…).scales('color') exposes a categorical color scale", () => { + const data = ["a", "b", "c", "d"]; + const color = Plot.dot(data, {y: d => d, fill: d => d}).plot({ color: { type: "categorical" }}).scales("color"); + assert.deepStrictEqual(color.domain, data); + assert.deepStrictEqual(color.range, ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab']); + assert.strictEqual(typeof color.interpolate, "undefined"); + assert.strictEqual(color.type, "categorical"); + assert.strictEqual(color.clamp, undefined); + assert.strictEqual(typeof Plot.scale(color), "function"); +}); + +it("plot(…).scales('r') exposes a radius scale", () => { + const r0 = Plot.dot([1, 2], {x: d => d}).plot().scales("r"); + assert.strictEqual(r0, undefined); + const data = [1, 2, 3, 4, 9]; + const r = Plot.dot(data, {r: d => d}).plot().scales("r"); + assert.deepStrictEqual(r.domain, [0, 9]); + assert.deepStrictEqual(r.range, [0, Math.sqrt(40.5)]); + assert.strictEqual(typeof r.interpolate, "function"); + assert.strictEqual(r.type, "sqrt"); + assert.strictEqual(r.clamp, false); + assert.strictEqual(typeof Plot.scale(r), "function"); +}); + +it("plot(…).scales('opacity') exposes a linear scale", () => { + const opacity0 = Plot.dot([1, 2], {x: d => d}).plot().scales("opacity"); + assert.strictEqual(opacity0, undefined); + const data = [1, 2, 3, 4, 9]; + const opacity = Plot.dot(data, {fillOpacity: d => d}).plot().scales("opacity"); + assert.deepStrictEqual(opacity.domain, [0, 9]); + assert.deepStrictEqual(opacity.range, [0, 1]); + assert.strictEqual(typeof opacity.interpolate, "function"); + assert.strictEqual(opacity.type, "linear"); + assert.strictEqual(opacity.clamp, false); + assert.strictEqual(typeof Plot.scale(opacity), "function"); +}); + +it("plot(…).scales expose inset domain", () => { + assert.deepStrictEqual(scaleOpt({inset: null}).range, [20, 620]); + assert.deepStrictEqual(scaleOpt({inset: 7}).range, [27, 613]); +}); + +it("plot(…).scales expose clamp", () => { + assert.strictEqual(scaleOpt({clamp: false}).clamp, false); + assert.strictEqual(scaleOpt({clamp: true}).clamp, true); +}); + +it("plot(…).scales expose rounded scales", () => { + assert.strictEqual(Plot.scale(scaleOpt({round: false}))(Math.SQRT2), 144.26406871192853); + assert.strictEqual(Plot.scale(scaleOpt({round: true}))(Math.SQRT2), 144); + assert.strictEqual(scaleOpt({round: true}).interpolate(0, 100)(Math.SQRT1_2), 71); +}); + +it("plot(…).scales expose label", () => { + assert.strictEqual(scaleOpt({}).label, "x →"); + assert.strictEqual(scaleOpt({label: "value"}).label, "value"); +}); + +it("plot(…).scales expose color label", () => { + const x = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {fill: "x"}).plot().scales("color"); + assert.strictEqual(x.label, "x"); + const y = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {fill: "x"}).plot({color: {label: "y"}}).scales("color"); + assert.strictEqual(y.label, "y"); +}); + +it("plot(…).scales expose radius label", () => { + const x = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {r: "x"}).plot().scales("r"); + assert.strictEqual(x.label, "x"); + const r = Plot.dot([{x: 1}, {x: 2}, {x: 3}], {r: "x"}).plot({r: {label: "radius"}}).scales("r"); + assert.strictEqual(r.label, "radius"); +}); + +it("plot(…).scales expose pow exponent", () => { + const x = Plot.dotX([]).plot({x: { type: "pow", exponent: 0.3 }}).scales("x"); + assert.strictEqual(x.type, "pow"); + assert.strictEqual(x.exponent, 0.3); + const y = Plot.dotX([]).plot({x: { type: "sqrt" }}).scales("x"); + assert.strictEqual(y.type, "sqrt"); + assert.strictEqual(y.exponent, undefined); +}); + +it("plot(…).scales expose log base", () => { + const x = Plot.dotX([]).plot({x: { type: "log", base: 2 }}).scales("x"); + assert.strictEqual(x.type, "log"); + assert.strictEqual(x.base, 2); +}); + +it("plot(…).scales expose symlog constant", () => { + const x = Plot.dotX([]).plot({x: { type: "symlog", constant: 42 }}).scales("x"); + assert.strictEqual(x.type, "symlog"); + assert.strictEqual(x.constant, 42); +}); + +it("plot(…).scales expose align, paddingInner and paddingOuter", () => { + const x = Plot.cellX(["A", "B"]).plot({x: { paddingOuter: -0.2, align: 1 }}).scales("x"); + assert.strictEqual(x.type, "band"); + assert.strictEqual(x.align, 1); + assert.strictEqual(x.paddingInner, 0.1); + assert.strictEqual(x.paddingOuter, -0.2); +}); + +it("plot(…).scales expose unexpected scale options", () => { + const x = Plot.dotX([]).plot({x: { lala: 42, width: 420 }}).scales("x"); + assert.strictEqual(x.lala, 42); + assert.strictEqual(x.width, 420); +}); + +function scaleOpt(x) { + return Plot.dot([{x: 1}, {x: 2}, {x: 3}], {x: "x"}).plot({x}).scales("x"); +} diff --git a/yarn.lock b/yarn.lock index f511225f26..f816acb5b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -59,6 +59,21 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== +"@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.8.2" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-2.8.2.tgz#643f8c8a26ffbaa579983972f67a60cb6217e86a" @@ -680,6 +695,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" @@ -1236,6 +1260,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" @@ -1294,6 +1325,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" @@ -2523,7 +2559,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== @@ -2584,6 +2620,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" @@ -2728,6 +2769,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.23: version "3.1.23" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" @@ -2748,6 +2794,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.2.3" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.2.3.tgz#ce6277f853835f718829efb47db20f3e4d9c4739" @@ -3529,6 +3580,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.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" + skypack@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/skypack/-/skypack-0.3.2.tgz#9df9fde1ed73ae6874d15111f0636e16f2cab1b9"