diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c456013e..2179da6642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Observable Plot - Changelog +## 0.3.0 + +*Not yet released.* These notes are a work in progress. + + +### Marks + +The constant *dx* and *dy* options have been extended to all marks, allowing to shift the mark by *dx* pixels horizontally and *dy* pixels vertically. Since only text elements accept the dx and dy properties, in all the other marks these are rendered as a transform (2D transformation) property of the mark’s parent, possibly including a 0.5px offset on low-density screens. + +### Scales + +Quantitative scales, as well as identity position scales, now coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). + ## 0.2.0 Released August 20, 2021. diff --git a/README.md b/README.md index 1ef9354a64..7e67c0506c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ For ordinal data (*e.g.*, strings), use the *ordinal* scale type or the *point* You can opt-out of a scale using the *identity* scale type. This is useful if you wish to specify literal colors or pixel positions within a mark channel rather than relying on the scale to convert abstract values into visual values. For position scales (*x* and *y*), an *identity* scale is still quantitative and may produce an axis, yet unlike a *linear* scale the domain and range are fixed based on the plot layout. +Quantitative scales, as well as identity position scales, coerce channel values to numbers; both null and undefined are coerced to NaN. Similarly, time scales coerce channel values to dates; numbers are assumed to be milliseconds since UNIX epoch, while strings are assumed to be in [ISO 8601 format](https://github.com/mbostock/isoformat/blob/main/README.md#parsedate-fallback). + A scale’s domain (the extent of its inputs, abstract values) and range (the extent of its outputs, visual values) are typically inferred automatically. You can set them explicitly using these options: * *scale*.**domain** - typically [*min*, *max*], or an array of ordinal or categorical values @@ -196,6 +198,70 @@ Plot.plot({ }) ``` +All the scale definitions are exposed as 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: @@ -235,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 @@ -509,6 +575,10 @@ All marks support the following style options: * **strokeDasharray** - a comma-separated list of dash lengths (in pixels) * **mixBlendMode** - the [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) (*e.g.*, *multiply*) * **shapeRendering** - the [shape-rendering mode](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/shape-rendering) (*e.g.*, *crispEdges*) +* **dx** - horizontal offset (in pixels; defaults to 0) +* **dy** - vertical offset (in pixels; defaults to 0) + +For all marks except [text](#plottextdata-options), the **dx** and **dy** options are rendered as a transform property, possibly including a 0.5px offset on low-density screens. All marks support the following optional channels: @@ -889,11 +959,9 @@ The following text-specific constant options are also supported: * **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to normal * **fontVariant** - the [font variant](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant); defaults to normal * **fontWeight** - the [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight); defaults to normal -* **dx** - the horizontal offset; defaults to 0 -* **dy** - the vertical offset; defaults to 0 * **rotate** - the rotation in degrees clockwise; defaults to 0 -The **dx** and **dy** options can be specified either as numbers representing pixels or as a string including units. For example, `"1em"` shifts the text by one [em](https://en.wikipedia.org/wiki/Em_(typography)), which is proportional to the **fontSize**. The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. +For text marks, the **dx** and **dy** options can be specified either as numbers representing pixels or as a string including units. For example, `"1em"` shifts the text by one [em](https://en.wikipedia.org/wiki/Em_(typography)), which is proportional to the **fontSize**. The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. #### Plot.text(*data*, *options*) @@ -1532,7 +1600,7 @@ These helper functions are provided for use as a *scale*.tickFormat [axis option Plot.formatIsoDate(new Date("2020-01-01T00:00.000Z")) // "2020-01-01" ``` -Given a *date*, returns the shortest equivalent ISO 8601 UTC string. +Given a *date*, returns the shortest equivalent ISO 8601 UTC string. If the given *date* is not valid, returns `"Invalid Date"`. #### Plot.formatWeekday(*locale*, *format*) diff --git a/package.json b/package.json index b70b4f0c3e..52bd582487 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ }, "files": [ "dist/**/*.js", - "src/**/*.js" + "src/**/*.js", + "src/**/*.css" ], "scripts": { "test": "mkdir -p test/output && mocha -r module-alias/register 'test/**/*-test.js' && mocha -r module-alias/register test/plot.js && eslint src test", @@ -37,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", @@ -51,7 +53,7 @@ }, "dependencies": { "d3": "^7.0.0", - "isoformat": "^0.1.0" + "isoformat": "^0.2.0" }, "engines": { "node": ">=12" diff --git a/src/axes.js b/src/axes.js index cea4ad3c06..a849bed3fe 100644 --- a/src/axes.js +++ b/src/axes.js @@ -37,6 +37,7 @@ function autoAxisTicksK(scale, axis, k) { } // Mutates axis.{label,labelAnchor,labelOffset}! +// Mutates scale.label! export function autoAxisLabels(channels, scales, {x, y, fx, fy}, dimensions) { if (fx) { autoAxisLabelsX(fx, scales.fx, channels.get("fx")); @@ -70,24 +71,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 +115,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/format.js b/src/format.js index 2b7334eab4..737415bbf0 100644 --- a/src/format.js +++ b/src/format.js @@ -1,4 +1,4 @@ -export {default as formatIsoDate} from "isoformat"; +import {format as isoFormat} from "isoformat"; export function formatMonth(locale = "en-US", month = "short") { const format = new Intl.DateTimeFormat(locale, {timeZone: "UTC", month}); @@ -17,3 +17,7 @@ export function formatWeekday(locale = "en-US", weekday = "short") { } }; } + +export function formatIsoDate(date) { + return isoFormat(date, "Invalid Date"); +} 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..3545e58982 --- /dev/null +++ b/src/legends.js @@ -0,0 +1,18 @@ +import {legendColor} from "./legends/color.js"; +import {legendOpacity} from "./legends/opacity.js"; +import {legendRadius} from "./legends/radius.js"; + +export function createLegends(descriptors, dimensions) { + const legends = []; + for (let key in descriptors) { + let {legend, ...options} = descriptors[key]; + if (key === "color" && legend === true) legend = legendColor; + if (key === "opacity" && legend === true) legend = legendOpacity; + if (key === "r" && legend === true) legend = legendRadius; + if (typeof legend === "function") { + const l = legend(options, dimensions); + if (l instanceof Node) legends.push(l); + } + } + return legends; +} diff --git a/src/legends/color.js b/src/legends/color.js new file mode 100644 index 0000000000..3743f146a8 --- /dev/null +++ b/src/legends/color.js @@ -0,0 +1,19 @@ +import {scale} from "../scales.js"; +import {legendRamp} from "./ramp.js"; +import {legendSwatches} from "./swatches.js"; + +export function legendColor(color, {width: maxWidth = 640} = {}) { + if (typeof color === "object" && "scales" in color) { + color = color.scales.color; + } + if (!color) return; + const {...options} = color; + switch (options.type) { + case "ordinal": case "categorical": + return legendSwatches(scale(options), options); + default: + options.key = "color"; // for diverging + if (options.width === undefined) options.width = Math.min(240, maxWidth); + return legendRamp(scale(options), options); + } +} diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..2986673c7b --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,13 @@ +import {legendColor} from "./color.js"; + +export function legendOpacity(plotOrScale, dimensions) { + if (!plotOrScale) return; + const opacity = "scales" in plotOrScale ? plotOrScale.scales.opacity : plotOrScale; + if (!opacity) return; + return legendColor({ + ...opacity, + range: undefined, + interpolate: undefined, + scheme: "greys" + }, dimensions); +} diff --git a/src/legends/radius.js b/src/legends/radius.js new file mode 100644 index 0000000000..53fdab3243 --- /dev/null +++ b/src/legends/radius.js @@ -0,0 +1,64 @@ +import {plot} from "../plot.js"; +import {link} from "../marks/link.js"; +import {text} from "../marks/text.js"; +import {dot} from "../marks/dot.js"; +import {scale} from "../scales.js"; + +export function legendRadius({ + label, + ticks = 5, + tickFormat = (d) => d, + strokeWidth = 0.5, + strokeDasharray = [5, 4], + minStep = 8, + gap = 20, + ...r +}) { + const s = scale(r); + const r0 = s.range()[1]; + + const shiftY = label ? 10 : 0; + + let h = Infinity; + const values = s + .ticks(ticks) + .reverse() + .filter((t) => h - s(t) > minStep / 2 && (h = s(t))); + + return plot({ + x: { type: "identity", axis: null }, + r: { type: "identity" }, + y: { type: "identity", axis: null }, + marks: [ + link(values, { + x1: r0 + 2, + y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + x2: 2 * r0 + 2 + gap, + y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + strokeWidth: strokeWidth / 2, + strokeDasharray + }), + dot(values, { + r: s, + x: r0 + 2, + y: (d) => 8 + 2 * r0 - s(d) + shiftY, + strokeWidth + }), + text(values, { + x: 2 * r0 + 2 + gap, + y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, + textAnchor: "start", + dx: 4, + text: tickFormat + }), + text(label ? [label] : [], { + x: 0, + y: 6, + textAnchor: "start", + fontWeight: "bold", + text: tickFormat + }) + ], + height: 2 * r0 + 10 + shiftY + }); +} diff --git a/src/legends/ramp.js b/src/legends/ramp.js new file mode 100644 index 0000000000..c4b13c5fa9 --- /dev/null +++ b/src/legends/ramp.js @@ -0,0 +1,153 @@ +import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3"; + +export function legendRamp(color, { + label, + tickSize = 6, + width = 240, + height = 44 + tickSize, + marginTop = 18, + marginRight = 0, + marginBottom = 16 + tickSize, + marginLeft = 0, + ticks = width / 64, + tickFormat, + tickValues +} = {}) { + const svg = create("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .style("overflow", "visible") + .style("display", "block"); + + let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height); + let x; + + // Continuous + if (color.interpolate) { + const n = Math.min(color.domain().length, color.range().length); + x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n)); + let color2 = color.copy().domain(quantize(interpolate(0, 1), n)); + // special case for log scales + if (color.base) { + const p = scaleLinear( + quantize(interpolate(0, 1), color.domain().length), + color.domain().map(d => Math.log(d)) + ); + color2 = t => color(Math.exp(p(t))); + } + svg.append("image") + .attr("x", marginLeft) + .attr("y", marginTop) + .attr("width", width - marginLeft - marginRight) + .attr("height", height - marginTop - marginBottom) + .attr("preserveAspectRatio", "none") + .attr("xlink:href", ramp(color2).toDataURL()); + } + + // Sequential + else if (color.interpolator) { + x = Object.assign(color.copy() + .interpolator(interpolateRound(marginLeft, width - marginRight)), + {range() { return [marginLeft, width - marginRight]; }}); + + svg.append("image") + .attr("x", marginLeft) + .attr("y", marginTop) + .attr("width", width - marginLeft - marginRight) + .attr("height", height - marginTop - marginBottom) + .attr("preserveAspectRatio", "none") + .attr("xlink:href", ramp(color.interpolator()).toDataURL()); + + // scaleSequentialQuantile doesn’t implement ticks or tickFormat. + if (!x.ticks) { + if (tickValues === undefined) { + const n = Math.round(ticks + 1); + tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1))); + } + if (typeof tickFormat !== "function") { + tickFormat = format(tickFormat === undefined ? ",f" : tickFormat); + } + } + } + + // Threshold + else if (color.invertExtent) { + const thresholds + = color.thresholds ? color.thresholds() // scaleQuantize + : color.quantiles ? color.quantiles() // scaleQuantile + : color.domain(); // scaleThreshold + + const thresholdFormat + = tickFormat === undefined ? d => d + : typeof tickFormat === "string" ? format(tickFormat) + : tickFormat; + + x = scaleLinear() + .domain([-1, color.range().length - 1]) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.range()) + .join("rect") + .attr("x", (d, i) => x(i - 1)) + .attr("y", marginTop) + .attr("width", (d, i) => x(i) - x(i - 1)) + .attr("height", height - marginTop - marginBottom) + .attr("fill", d => d); + + tickValues = range(thresholds.length); + tickFormat = i => thresholdFormat(thresholds[i], i); + } + + // Ordinal + else { + x = scaleBand() + .domain(color.domain()) + .rangeRound([marginLeft, width - marginRight]); + + svg.append("g") + .selectAll("rect") + .data(color.domain()) + .join("rect") + .attr("x", x) + .attr("y", marginTop) + .attr("width", Math.max(0, x.bandwidth() - 1)) + .attr("height", height - marginTop - marginBottom) + .attr("fill", color); + + tickAdjust = () => {}; + } + + svg.append("g") + .attr("transform", `translate(0,${height - marginBottom})`) + .call(axisBottom(x) + .ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined) + .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined) + .tickSize(tickSize) + .tickValues(tickValues)) + .call(tickAdjust) + .call(g => g.select(".domain").remove()) + .call(label === undefined ? () => {} + : g => g.append("text") + .attr("x", marginLeft) + .attr("y", marginTop + marginBottom - height - 6) + .attr("fill", "currentColor") + .attr("text-anchor", "start") + .attr("font-weight", "bold") + .attr("class", "label") + .text(label)); + + return svg.node(); +} + +function ramp(color, n = 256) { + const canvas = create("canvas").attr("width", n).attr("height", 1).node(); + const context = canvas.getContext("2d"); + for (let i = 0; i < n; ++i) { + context.fillStyle = color(i / (n - 1)); + context.fillRect(i, 0, 1, 1); + } + return canvas; +} diff --git a/src/legends/swatches.js b/src/legends/swatches.js new file mode 100644 index 0000000000..67452208d1 --- /dev/null +++ b/src/legends/swatches.js @@ -0,0 +1,46 @@ +import {create} from "d3"; + +export function legendSwatches(color, { + columns = null, + format = x => x, + swatchSize = 15, + swatchWidth = swatchSize, + swatchHeight = swatchSize, + marginLeft = 0, + width +} = {}) { + const swatches = create("div") + .classed("plot-swatches", true) + .attr("style", `--marginLeft: ${+marginLeft}px; --swatchWidth: ${+swatchWidth}px; --swatchHeight: ${+swatchHeight}px;${ + width === undefined ? "" : ` width: ${width}px;` + }`); + + if (columns !== null) { + const elems = swatches.append("div") + .style("columns", columns); + for (const value of color.domain()) { + const d = elems.append("div").classed("swatch-item", true); + d.append("div") + .classed("swatch-block", true) + .style("background", color(value)); + const label = format(value); + d.append("div") + .classed("swatch-label", true) + .text(label) + .attr("title", label.replace(/["&]/g, entity)); + } + } else { + swatches + .selectAll() + .data(color.domain()) + .join("span") + .classed("plot-swatch", true) + .style("--color", color) + .text(format); + } + return swatches.node(); +} + +function entity(character) { + return `${character.charCodeAt(0).toString()};`; +} diff --git a/src/mark.js b/src/mark.js index 463c1a3c53..2edcd8a548 100644 --- a/src/mark.js +++ b/src/mark.js @@ -13,7 +13,7 @@ const objectToString = Object.prototype.toString; export class Mark { constructor(data, channels = [], options = {}, defaults) { - const {facet = "auto", sort} = options; + const {facet = "auto", sort, dx, dy} = options; const names = new Set(); this.data = data; this.sort = isOptions(sort) ? sort : null; @@ -35,6 +35,8 @@ export class Mark { } return true; }); + this.dx = +dx || 0; + this.dy = +dy || 0; } initialize(facets, facetChannels) { let data = arrayify(this.data); diff --git a/src/marks/area.js b/src/marks/area.js index 15b85f18e0..1488eb5b6d 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -29,9 +29,10 @@ export class Area extends Mark { } render(I, {x, y}, channels) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z} = channels; + const {dx, dy} = this; return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, x, y) + .call(applyTransform, x, y, dx, dy) .call(g => g.selectAll() .data(Z ? group(I, i => Z[i]).values() : [I]) .join("path") diff --git a/src/marks/bar.js b/src/marks/bar.js index fc1c84c87c..9b223edadc 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -19,11 +19,11 @@ export class AbstractBar extends Mark { this.ry = impliedString(ry, "auto"); } render(I, scales, channels, dimensions) { - const {rx, ry} = this; + const {dx, dy, rx, ry} = this; const index = filter(I, ...this._positions(channels)); return create("svg:g") .call(applyIndirectStyles, this) - .call(this._transform, scales) + .call(this._transform, scales, dx, dy) .call(g => g.selectAll() .data(index) .join("rect") @@ -70,8 +70,8 @@ export class BarX extends AbstractBar { options ); } - _transform(selection, {x}) { - selection.call(applyTransform, x, null); + _transform(selection, {x}, dx, dy) { + selection.call(applyTransform, x, null, dx, dy); } _positions({x1: X1, x2: X2, y: Y}) { return [X1, X2, Y]; @@ -99,8 +99,8 @@ export class BarY extends AbstractBar { options ); } - _transform(selection, {y}) { - selection.call(applyTransform, null, y); + _transform(selection, {y}, dx, dy) { + selection.call(applyTransform, null, y, dx, dy); } _positions({y1: Y1, y2: Y2, x: X}) { return [Y1, Y2, X]; diff --git a/src/marks/dot.js b/src/marks/dot.js index 33158bfc10..2fdb88a3e7 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,7 +1,7 @@ import {create} from "d3"; import {filter, positive} from "../defined.js"; import {Mark, identity, maybeNumber, maybeTuple} from "../mark.js"; -import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { fill: "none", @@ -32,11 +32,12 @@ export class Dot extends Mark { {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { const {x: X, y: Y, r: R} = channels; + const {dx, dy} = this; let index = filter(I, X, Y); if (R) index = index.filter(i => positive(R[i])); return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, x, y, 0.5, 0.5) + .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() .data(index) .join("circle") diff --git a/src/marks/frame.js b/src/marks/frame.js index 5e6d4a9108..8da9eba0c9 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {Mark, number} from "../mark.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { fill: "none", @@ -24,11 +24,11 @@ export class Frame extends Mark { } render(I, scales, channels, dimensions) { const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; - const {insetTop, insetRight, insetBottom, insetLeft} = this; + const {insetTop, insetRight, insetBottom, insetLeft, dx, dy} = this; return create("svg:rect") .call(applyIndirectStyles, this) .call(applyDirectStyles, this) - .call(applyTransform, null, null, 0.5, 0.5) + .call(applyTransform, null, null, offset + dx, offset + dy) .attr("x", marginLeft + insetLeft) .attr("y", marginTop + insetTop) .attr("width", width - marginLeft - marginRight - insetLeft - insetRight) diff --git a/src/marks/line.js b/src/marks/line.js index 20e93ac7f4..6630592993 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -2,7 +2,7 @@ import {create, group, line as shapeLine} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; import {Mark, indexOf, identity, maybeTuple, maybeZ} from "../mark.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js"; const defaults = { fill: "none", @@ -28,9 +28,10 @@ export class Line extends Mark { } render(I, {x, y}, channels) { const {x: X, y: Y, z: Z} = channels; + const {dx, dy} = this; return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, x, y, 0.5, 0.5) + .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() .data(Z ? group(I, i => Z[i]).values() : [I]) .join("path") diff --git a/src/marks/link.js b/src/marks/link.js index 96997adb45..6fcdae8249 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -2,7 +2,7 @@ import {create, path} from "d3"; import {filter} from "../defined.js"; import {Mark} from "../mark.js"; import {Curve} from "../curve.js"; -import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { fill: "none", @@ -28,10 +28,11 @@ export class Link extends Mark { } render(I, {x, y}, channels) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; + const {dx, dy} = this; const index = filter(I, X1, Y1, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, x, y, 0.5, 0.5) + .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() .data(index) .join("path") diff --git a/src/marks/rect.js b/src/marks/rect.js index fb648c9b7e..f303751771 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -43,11 +43,11 @@ export class Rect extends Mark { render(I, {x, y}, channels, dimensions) { const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; - const {insetTop, insetRight, insetBottom, insetLeft, rx, ry} = this; + const {insetTop, insetRight, insetBottom, insetLeft, dx, dy, rx, ry} = this; const index = filter(I, X1, Y2, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, x, y) + .call(applyTransform, x, y, dx, dy) .call(g => g.selectAll() .data(index) .join("rect") diff --git a/src/marks/rule.js b/src/marks/rule.js index 68d5f2c28a..bfad5a0067 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -2,7 +2,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; import {Mark, identity, number} from "../mark.js"; import {isCollapsed} from "../scales.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; const defaults = { fill: null, @@ -39,7 +39,7 @@ export class RuleX extends Mark { const index = filter(I, X, Y1, Y2); return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, X && x, null, 0.5, 0) + .call(applyTransform, X && x, null, offset, 0) .call(g => g.selectAll("line") .data(index) .join("line") @@ -79,11 +79,11 @@ export class RuleY extends Mark { render(I, {x, y}, channels, dimensions) { const {y: Y, x1: X1, x2: X2} = channels; const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; - const {insetLeft, insetRight} = this; + const {insetLeft, insetRight, dx, dy} = this; const index = filter(I, Y, X1, X2); return create("svg:g") .call(applyIndirectStyles, this) - .call(applyTransform, null, Y && y, 0, 0.5) + .call(applyTransform, null, Y && y, dx, offset + dy) .call(g => g.selectAll("line") .data(index) .join("line") diff --git a/src/marks/text.js b/src/marks/text.js index d2c37618a3..c90376336a 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -1,7 +1,7 @@ import {create} from "d3"; import {filter, nonempty} from "../defined.js"; import {Mark, indexOf, identity, string, maybeNumber, maybeTuple, numberChannel} from "../mark.js"; -import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyTransform} from "../style.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyTransform, offset} from "../style.js"; const defaults = {}; @@ -54,7 +54,7 @@ export class Text extends Mark { const cy = (marginTop + height - marginBottom) / 2; return create("svg:g") .call(applyIndirectTextStyles, this) - .call(applyTransform, x, y, 0.5, 0.5) + .call(applyTransform, x, y, offset, offset) .call(g => g.selectAll() .data(index) .join("text") diff --git a/src/marks/tick.js b/src/marks/tick.js index 5aadfbedff..429ee08928 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -1,7 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; import {Mark, identity, number} from "../mark.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; const defaults = { fill: null, @@ -14,10 +14,11 @@ class AbstractTick extends Mark { } render(I, scales, channels, dimensions) { const {x: X, y: Y} = channels; + const {dx, dy} = this; const index = filter(I, X, Y); return create("svg:g") .call(applyIndirectStyles, this) - .call(this._transform, scales) + .call(this._transform, scales, dx, dy) .call(g => g.selectAll("line") .data(index) .join("line") @@ -51,8 +52,8 @@ export class TickX extends AbstractTick { this.insetTop = number(insetTop); this.insetBottom = number(insetBottom); } - _transform(selection, {x}) { - selection.call(applyTransform, x, null, 0.5, 0); + _transform(selection, {x}, dx, dy) { + selection.call(applyTransform, x, null, offset + dx, dy); } _x1(scales, {x: X}) { return i => X[i]; @@ -90,8 +91,8 @@ export class TickY extends AbstractTick { this.insetRight = number(insetRight); this.insetLeft = number(insetLeft); } - _transform(selection, {y}) { - selection.call(applyTransform, null, y, 0, 0.5); + _transform(selection, {y}, dx, dy) { + selection.call(applyTransform, null, y, dx, offset + dy); } _x1(scales, {x: X}, {marginLeft}) { const {insetLeft} = this; diff --git a/src/plot.js b/src/plot.js index e2d526853f..8fcffda4df 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,8 +1,10 @@ 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 {Scales, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {figureWrap} from "./figure.js"; +import {createLegends} from "./legends.js"; import {markify} from "./mark.js"; -import {Scales, autoScaleRange, applyScales} from "./scales.js"; import {filterStyles, offset} from "./style.js"; export function plot(options = {}) { @@ -52,6 +54,9 @@ export function plot(options = {}) { autoScaleRange(scaleDescriptors, dimensions); autoAxisTicks(scaleDescriptors, axes); autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions); + for (const key of ["color", "r", "opacity"]) { + autoScaleLabel(scaleDescriptors[key], scaleChannels.get(key), options[key]); + } // Normalize the options. options = {...scaleDescriptors, ...dimensions}; @@ -89,12 +94,10 @@ export function plot(options = {}) { if (node != null) svg.appendChild(node); } - // 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 = descriptors; return figure; } @@ -141,6 +144,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 dec9731463..df4919caa9 100644 --- a/src/scales.js +++ b/src/scales.js @@ -4,6 +4,7 @@ import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog import {ScaleTime, ScaleUtc} from "./scales/temporal.js"; import {ScaleOrdinal, ScalePoint, ScaleBand} from "./scales/ordinal.js"; import {isOrdinal, isTemporal} from "./mark.js"; +import {parse as isoParse} from "isoformat"; export function Scales(channels, {inset, round, nice, align, padding, ...options} = {}) { const scales = {}; @@ -45,20 +46,51 @@ 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 = {}) { - switch (inferScaleType(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. + switch (type) { + case "diverging": + case "diverging-sqrt": + case "diverging-pow": + case "diverging-log": + case "diverging-symlog": + case "cyclical": + case "sequential": + case "linear": + case "sqrt": + case "threshold": + case "quantile": + case "pow": + case "log": + case "symlog": + options = coerceType(channels, options, coerceNumber, Float64Array); + break; + case "identity": + if (registry.get(key) === position) options = coerceType(channels, options, coerceNumber, Float64Array); + break; + case "utc": + case "time": + options = coerceType(channels, options, coerceDate); + break; + } + + switch (type) { case "diverging": return ScaleDiverging(key, channels, options); case "diverging-sqrt": return ScaleDivergingSqrt(key, channels, options); case "diverging-pow": return ScaleDivergingPow(key, channels, options); @@ -82,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) { @@ -97,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"; } @@ -109,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? @@ -144,3 +180,59 @@ export function isCollapsed(scale) { } return true; } + +// Mutates channel.value! +function coerceType(channels, options, coerce, type) { + for (const c of channels) c.value = coerceArray(c.value, coerce, type); + return {...options, domain: coerceArray(options.domain, coerce, type)}; +} + +function coerceArray(array, coerce, type = Array) { + if (array !== undefined) return type.from(array, coerce); +} + +// Unlike Mark’s number, here we want to convert null and undefined to NaN, +// since the result will be stored in a Float64Array and we don’t want null to +// be coerced to zero. +function coerceNumber(x) { + return x == null ? NaN : +x; +} + +// When coercing strings to dates, we only want to allow the ISO 8601 format +// since the built-in string parsing of the Date constructor varies across +// browsers. (In the future, this could be made more liberal if desired, though +// it is still generally preferable to do date parsing yourself explicitly, +// rather than rely on Plot.) Any non-string values are coerced to number first +// and treated as milliseconds since UNIX epoch. +function coerceDate(x) { + return x instanceof Date && !isNaN(x) ? x + : typeof x === "string" ? isoParse(x) + : x == null || isNaN(x = +x) ? undefined + : new Date(x); +} + +// prepare scales for exposure through the plot's scales() function +export function exposeScales(scaleDescriptors) { + const scales = {}; + for (const key in scaleDescriptors) { + let cache; + Object.defineProperty(scales, key, { + enumerable: true, + get: () => cache = cache || exposeScale(scaleDescriptors[key]) + }); + } + return scales; +} + +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..41320c4de1 100644 --- a/src/style.css +++ b/src/style.css @@ -10,3 +10,51 @@ .plot text { white-space: pre; } + +.plot-swatches { + display: flex; + align-items: center; + margin-left: var(--marginLeft); + min-height: 33px; + font: 10px sans-serif; + margin-bottom: 0.5em; +} + +.plot-swatches > div { + width: 100%; +} + +.plot-swatches .swatch-item { + break-inside: avoid; + display: flex; + align-items: center; + padding-bottom: 1px; +} + +.plot-swatches .swatch-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - var(--swatchWidth) - 0.5em); +} + +.plot-swatches .swatch-block { + width: var(--swatchWidth); + height: var(--swatchHeight); + margin: 0 0.5em 0 0; +} + +.plot-swatch { + display: inline-flex; + align-items: center; + margin-right: 1em; +} + +.plot-swatch::before { + content: ""; + width: var(--swatchWidth); + height: var(--swatchHeight); + margin-right: 0.5em; + background: var(--color); +} + diff --git a/src/style.js b/src/style.js index 57181e74b2..5bf1fcf4a9 100644 --- a/src/style.js +++ b/src/style.js @@ -131,8 +131,6 @@ export function applyStyle(selection, name, value) { } export function applyTransform(selection, x, y, tx, ty) { - tx = tx ? offset : 0; - ty = ty ? offset : 0; if (x && x.bandwidth) tx += x.bandwidth() / 2; if (y && y.bandwidth) ty += y.bandwidth() / 2; if (tx || ty) selection.attr("transform", `translate(${tx},${ty})`); diff --git a/test/output/aaplCloseUntyped.svg b/test/output/aaplCloseUntyped.svg new file mode 100644 index 0000000000..3800f7e59e --- /dev/null +++ b/test/output/aaplCloseUntyped.svg @@ -0,0 +1,67 @@ + + + + + 0 + + + + 20 + + + + 40 + + + + 60 + + + + 80 + + + + 100 + + + + 120 + + + + 140 + + + + 160 + + + + 180 + ↑ Close + + + + 2014 + + + 2015 + + + 2016 + + + 2017 + + + 2018 + + + + + + + + + \ No newline at end of file diff --git a/test/output/athletesHeightWeightBin.html b/test/output/athletesHeightWeightBin.html new file mode 100644 index 0000000000..a8cc6da74f --- /dev/null +++ b/test/output/athletesHeightWeightBin.html @@ -0,0 +1,955 @@ + + + + + + 0 + + + 20 + + + 40 + + + 60 + + + 80 + + + 100 + Frequency + + + + + + 1.2 + + + + 1.3 + + + + 1.4 + + + + 1.5 + + + + 1.6 + + + + 1.7 + + + + 1.8 + + + + 1.9 + + + + 2.0 + + + + 2.1 + + + + 2.2 + ↑ height + + + + + 40 + + + + 60 + + + + 80 + + + + 100 + + + + 120 + + + + 140 + + + + 160 + weight → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/athletesHeightWeightBin.svg b/test/output/athletesHeightWeightBin.svg deleted file mode 100644 index af6b696d38..0000000000 --- a/test/output/athletesHeightWeightBin.svg +++ /dev/null @@ -1,931 +0,0 @@ - - - - - 1.2 - - - - 1.3 - - - - 1.4 - - - - 1.5 - - - - 1.6 - - - - 1.7 - - - - 1.8 - - - - 1.9 - - - - 2.0 - - - - 2.1 - - - - 2.2 - ↑ height - - - - - 40 - - - - 60 - - - - 80 - - - - 100 - - - - 120 - - - - 140 - - - - 160 - weight → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/figcaption.html b/test/output/figcaption.html index 822194ec6f..6ce63087e8 100644 --- a/test/output/figcaption.html +++ b/test/output/figcaption.html @@ -1,4 +1,4 @@ - + diff --git a/test/output/figcaptionHtml.html b/test/output/figcaptionHtml.html index f0c8da2b4b..e833a7a241 100644 --- a/test/output/figcaptionHtml.html +++ b/test/output/figcaptionHtml.html @@ -1,4 +1,4 @@ - + diff --git a/test/output/industryUnemploymentShare.html b/test/output/industryUnemploymentShare.html new file mode 100644 index 0000000000..489cd05c33 --- /dev/null +++ b/test/output/industryUnemploymentShare.html @@ -0,0 +1,193 @@ + + + + + + + Agriculture + + + + Business services + + + + Construction + + + + Education and Health + + + + Finance + + + + Government + + + + Information + + + + Leisure and hospitality + + + + Manufacturing + + + + Mining and Extraction + + + + Other + + + + Self-employed + + + + Transportation and Utilities + + + + Wholesale and Retail Trade + + + + + + + + 0% + + + + 10% + + + + 20% + + + + 30% + + + + 40% + + + + 50% + + + + 60% + + + + 70% + + + + 80% + + + + 90% + + + + 100% + ↑ unemployed + + + + 2000 + + + 2001 + + + 2002 + + + 2003 + + + 2004 + + + 2005 + + + 2006 + + + 2007 + + + 2008 + + + 2009 + + + 2010 + + + + + Wholesale and Retail Trade + + + Manufacturing + + + Leisure and hospitality + + + Business services + + + Construction + + + Education and Health + + + Government + + + Finance + + + Self-employed + + + Other + + + Transportation and Utilities + + + Information + + + Agriculture + + + Mining and Extraction + + + + + + + \ No newline at end of file diff --git a/test/output/industryUnemploymentShare.svg b/test/output/industryUnemploymentShare.svg deleted file mode 100644 index 36c8143875..0000000000 --- a/test/output/industryUnemploymentShare.svg +++ /dev/null @@ -1,130 +0,0 @@ - - - - - 0% - - - - 10% - - - - 20% - - - - 30% - - - - 40% - - - - 50% - - - - 60% - - - - 70% - - - - 80% - - - - 90% - - - - 100% - ↑ unemployed - - - - 2000 - - - 2001 - - - 2002 - - - 2003 - - - 2004 - - - 2005 - - - 2006 - - - 2007 - - - 2008 - - - 2009 - - - 2010 - - - - - Wholesale and Retail Trade - - - Manufacturing - - - Leisure and hospitality - - - Business services - - - Construction - - - Education and Health - - - Government - - - Finance - - - Self-employed - - - Other - - - Transportation and Utilities - - - Information - - - Agriculture - - - Mining and Extraction - - - - - - \ No newline at end of file diff --git a/test/output/penguinSpeciesIsland.html b/test/output/penguinSpeciesIsland.html new file mode 100644 index 0000000000..522226d636 --- /dev/null +++ b/test/output/penguinSpeciesIsland.html @@ -0,0 +1,61 @@ + + + BiscoeDreamTorgersen + + + + + 0 + + + + 20 + + + + 40 + + + + 60 + + + + 80 + + + + 100 + + + + 120 + + + + 140 + ↑ Frequency + + + + Adelie + + + Chinstrap + + + Gentoo + species + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinSpeciesIsland.svg b/test/output/penguinSpeciesIsland.svg deleted file mode 100644 index cc449d8f7d..0000000000 --- a/test/output/penguinSpeciesIsland.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - 0 - - - - 20 - - - - 40 - - - - 60 - - - - 80 - - - - 100 - - - - 120 - - - - 140 - ↑ Frequency - - - - Adelie - - - Chinstrap - - - Gentoo - species - - - - - - - - - - - - \ No newline at end of file diff --git a/test/plot.js b/test/plot.js index 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/aapl-close-untyped.js b/test/plots/aapl-close-untyped.js new file mode 100644 index 0000000000..ee0fb270b0 --- /dev/null +++ b/test/plots/aapl-close-untyped.js @@ -0,0 +1,19 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const AAPL = await d3.csv("data/aapl.csv"); + return Plot.plot({ + x: { + type: "utc" + }, + y: { + type: "linear", + grid: true + }, + marks: [ + Plot.line(AAPL, {x: "Date", y: "Close"}), + Plot.ruleY([0]) + ] + }); +} 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/index.js b/test/plots/index.js index a74e855f11..b82ed6e311 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -1,6 +1,7 @@ export {default as aaplCandlestick} from "./aapl-candlestick.js"; export {default as aaplChangeVolume} from "./aapl-change-volume.js"; export {default as aaplClose} from "./aapl-close.js"; +export {default as aaplCloseUntyped} from "./aapl-close-untyped.js"; export {default as aaplMonthly} from "./aapl-monthly.js"; export {default as aaplVolume} from "./aapl-volume.js"; export {default as anscombeQuartet} from "./anscombe-quartet.js"; 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..cbf1aa6349 --- /dev/null +++ b/test/scales/scales-test.js @@ -0,0 +1,207 @@ +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(); + assert.strictEqual(typeof plot.scales, "object"); + const scales = plot.scales; + assert.strictEqual(Object.entries(scales).length, 2); + assert("x" in scales); + assert("y" in scales); +}); + +it("plot(…).scales[key] is computed once", () => { + const plot = Plot.dot([1, 2], {x: d => d, y: d => d}).plot(); + assert(plot.scales.x === plot.scales.x); +}); + +it("plot(…).scales[key] is computed lazily", () => { + const plot = Plot.dot([1, 2], {x: d => d, y: d => d}).plot(); + assert(plot.scales.x === plot.scales.x); +}); + +it.only("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 b2e1250dad..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" @@ -2243,10 +2279,10 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= -isoformat@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/isoformat/-/isoformat-0.1.0.tgz#b693c1c9ee9ab02f1af5af41ceeae52bf501b233" - integrity sha512-4wCSk50Ov1PKbZ2m+YN0rUgQfF4NRkIavbhpW1mANEqD9HxBZ+j/fWk8hERq1yxn+CfWqvOac4m9axLuF0NfEw== +isoformat@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/isoformat/-/isoformat-0.2.0.tgz#52c3dce6c281adb6cb7f060895a731b7b2d52c1b" + integrity sha512-iyxQ94xMvUZryoHVaXg/TSLM318/aO7xS7Ute+t4MkvZ17IDfe9MkI/MQuu7XgxbmTiGkeggNj+1f6wmxF876Q== isstream@~0.1.2: version "0.1.2" @@ -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"