diff --git a/src/index.js b/src/index.js index 330fc618c2..953d7eaeef 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ export {plot} from "./plot.js"; export {Mark, marks} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {Arrow, arrow} from "./marks/arrow.js"; -export {auto, autoSpec} from "./marks/auto.js"; +export {auto, autoSpec, autoCompile} from "./marks/auto.js"; export {axisX, axisY, axisFx, axisFy, gridX, gridY, gridFx, gridFy} from "./marks/axis.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {boxX, boxY} from "./marks/box.js"; diff --git a/src/marks/auto.js b/src/marks/auto.js index 062bd43f46..940cc560da 100644 --- a/src/marks/auto.js +++ b/src/marks/auto.js @@ -5,16 +5,59 @@ import {dot} from "./dot.js"; import {line, lineX, lineY} from "./line.js"; import {ruleX, ruleY} from "./rule.js"; import {barX, barY} from "./bar.js"; -import {rect, rectX, rectY} from "./rect.js"; +import {rectX, rectY} from "./rect.js"; import {cell} from "./cell.js"; import {frame} from "./frame.js"; import {bin, binX, binY} from "../transforms/bin.js"; import {group, groupX, groupY} from "../transforms/group.js"; import {marks} from "../mark.js"; +export function autoCompile(data, options) { + let { + fx, + fy, + x: {zero: xZero}, + y: {zero: yZero}, + markImpl, + markOptions, + transform, + transformOptions, + colorMode + } = autoImpl(data, options); + + const markOptionsString = transform + ? `Plot.${transform.name}(${JSON.stringify(transformOptions)}, ${JSON.stringify(markOptions)})` + : JSON.stringify(markOptions); + const frame = fx != null || fy != null ? `Plot.frame({strokeOpacity: 0.1})` : null; + const rules = [xZero ? `Plot.ruleX([0])` : null, yZero ? `Plot.ruleY([0])` : null]; + const mark = `Plot.${markImpl.name}(data, ${markOptionsString})`; + return `Plot.plot({ + marks: [ + ${(colorMode === "stroke" ? [frame, ...rules, mark] : [frame, mark, ...rules]) + .filter((mark) => mark) + .join(",\n ")} + ] +})`; +} + export function autoSpec(data, options) { + const {x, y, fx, fy, color, size, mark} = autoImpl(data, options); + return {x, y, fx, fy, color, size, mark}; +} + +function autoImpl(data, options) { options = normalizeOptions(options); + // Greedily materialize columns for type inference; we’ll need them anyway to + // plot! Note that we don’t apply any type inference to the fx and fy + // channels, if present; these are always ordinal (at least for now). + const {x, y, color, size} = options; + const X = materializeValue(data, x); + const Y = materializeValue(data, y); + const C = materializeValue(data, color); + const S = materializeValue(data, size); + + // Compute the default options. let { fx, fy, @@ -25,10 +68,6 @@ export function autoSpec(data, options) { mark } = options; - // Lazily materialize x and y columns for type inference, if needed. - const {x, y} = options; - let X, Y; - // Determine the default reducer, if any. if (xReduce === undefined) xReduce = yReduce == null && xValue == null && sizeValue == null && yValue != null ? "count" : null; @@ -42,8 +81,8 @@ export function autoSpec(data, options) { colorReduce == null && xReduce == null && yReduce == null && - (xValue == null || isOrdinal((X ??= materializeValue(data, x)))) && - (yValue == null || isOrdinal((Y ??= materializeValue(data, y)))) + (xValue == null || isOrdinal(X)) && + (yValue == null || isOrdinal(Y)) ) { sizeReduce = "count"; } @@ -62,12 +101,10 @@ export function autoSpec(data, options) { mark = sizeValue != null || sizeReduce != null ? "dot" - : xZero || yZero || colorReduce != null // histogram or heatmap + : isZeroReducer(xReduce) || isZeroReducer(yReduce) || colorReduce != null // histogram or heatmap ? "bar" : xValue != null && yValue != null - ? isOrdinal((X ??= materializeValue(data, x))) || - isOrdinal((Y ??= materializeValue(data, y))) || - (xReduce == null && yReduce == null && !isMonotonic(X) && !isMonotonic(Y)) + ? isOrdinal(X) || isOrdinal(Y) || (xReduce == null && yReduce == null && !isMonotonic(X) && !isMonotonic(Y)) ? "dot" : "line" : xValue != null || yValue != null @@ -75,89 +112,33 @@ export function autoSpec(data, options) { : null; } - return { - fx: fx ?? null, - fy: fy ?? null, - x: { - value: xValue ?? null, - reduce: xReduce ?? null, - ...(xZero !== undefined && {zero: xZero}), // TODO realize default - ...xOptions - }, - y: { - value: yValue ?? null, - reduce: yReduce ?? null, - ...(yZero !== undefined && {zero: yZero}), // TODO realize default - ...yOptions - }, - color: { - value: colorValue ?? null, - reduce: colorReduce ?? null, - ...(colorColor !== undefined && {color: colorColor}) - }, - size: { - value: sizeValue ?? null, - reduce: sizeReduce ?? null - }, - mark - }; -} - -export function auto(data, options) { - options = normalizeOptions(options); - - // Greedily materialize columns for type inference; we’ll need them anyway to - // plot! Note that we don’t apply any type inference to the fx and fy - // channels, if present; these are always ordinal (at least for now). - const {x, y, color, size} = options; - const X = materializeValue(data, x); - const Y = materializeValue(data, y); - const C = materializeValue(data, color); - const S = materializeValue(data, size); - - // Compute the default options via autoSpec. - let { - fx, - fy, - x: {reduce: xReduce, zero: xZero, ...xOptions}, - y: {reduce: yReduce, zero: yZero, ...yOptions}, - color: {color: colorColor, reduce: colorReduce}, - size: {reduce: sizeReduce}, - mark - } = autoSpec(data, { - ...options, - x: {...x, value: X}, - y: {...y, value: Y}, - color: {...color, value: C}, - size: {...size, value: S} - }); - let Z; // may be set to null to disable series-by-color for line and area let colorMode; // "fill" or "stroke" // Determine the mark implementation. + let markImpl; if (mark != null) { switch (`${mark}`.toLowerCase()) { case "dot": - mark = dot; + markImpl = dot; colorMode = "stroke"; break; case "line": - mark = X && Y ? line : X ? lineX : lineY; // 1d line by index + markImpl = X && Y ? line : X ? lineX : lineY; // 1d line by index colorMode = "stroke"; if (isHighCardinality(C)) Z = null; // TODO only if z not set by user break; case "area": - mark = yZero ? areaY : xZero || (Y && isMonotonic(Y)) ? areaX : areaY; // favor areaY if unsure + markImpl = yZero ? areaY : xZero || (Y && isMonotonic(Y)) ? areaX : areaY; // favor areaY if unsure colorMode = "fill"; if (isHighCardinality(C)) Z = null; // TODO only if z not set by user break; case "rule": - mark = X ? ruleX : ruleY; + markImpl = X ? ruleX : ruleY; colorMode = "stroke"; break; case "bar": - mark = yZero + markImpl = yZero ? isOrdinalReduced(xReduce, X) ? barY : rectY @@ -171,7 +152,7 @@ export function auto(data, options) { ? barY : isOrdinalReduced(yReduce, Y) ? barX - : rect; + : rectY; colorMode = "fill"; break; default: @@ -211,23 +192,78 @@ export function auto(data, options) { if (transform) { if (transform === bin || transform === binX) markOptions.x = {value: X, ...xOptions}; if (transform === bin || transform === binY) markOptions.y = {value: Y, ...yOptions}; - markOptions = transform(transformOptions, markOptions); } // If zero-ness is not specified, default based on whether the resolved mark - // type will include a zero baseline. TODO Move this to autoSpec. + // type will include a zero baseline. if (xZero === undefined) - xZero = X && transform !== binX && (mark === barX || mark === areaX || mark === rectX || mark === ruleY); + xZero = + X && + transform !== bin && + transform !== binX && + (markImpl === barX || markImpl === areaX || markImpl === rectX || markImpl === ruleY); if (yZero === undefined) - yZero = Y && transform !== binY && (mark === barY || mark === areaY || mark === rectY || mark === ruleX); + yZero = + Y && + transform !== bin && + transform !== binY && + (markImpl === barY || markImpl === areaY || markImpl === rectY || markImpl === ruleX); + + return { + fx: fx ?? null, + fy: fy ?? null, + x: { + value: xValue ?? null, + reduce: xReduce ?? null, + zero: xZero ?? false, + ...xOptions + }, + y: { + value: yValue ?? null, + reduce: yReduce ?? null, + zero: yZero ?? false, + ...yOptions + }, + color: { + value: colorValue ?? null, + reduce: colorReduce ?? null, + ...(colorColor !== undefined && {color: colorColor}) + }, + size: { + value: sizeValue ?? null, + reduce: sizeReduce ?? null + }, + mark, + markImpl, + markOptions, + transform, + transformOptions, + colorMode + }; +} + +export function auto(data, options) { + let { + fx, + fy, + x: {zero: xZero}, + y: {zero: yZero}, + markImpl, + markOptions, + transform, + transformOptions, + colorMode + } = autoImpl(data, options); + + if (transform) markOptions = transform(transformOptions, markOptions); // In the case of filled marks (particularly bars and areas) the frame and // rules should come after the mark; in the case of stroked marks // (particularly dots and lines) they should come before the mark. const frames = fx != null || fy != null ? frame({strokeOpacity: 0.1}) : null; const rules = [xZero ? ruleX([0]) : null, yZero ? ruleY([0]) : null]; - mark = mark(data, markOptions); - return colorMode === "stroke" ? marks(frames, rules, mark) : marks(frames, mark, rules); + markImpl = markImpl(data, markOptions); + return colorMode === "stroke" ? marks(frames, rules, markImpl) : marks(frames, markImpl, rules); } // TODO What about sorted within series? @@ -253,6 +289,7 @@ function isMonotonic(values) { // (but note that they can also be specified as a {transform} object such as // Plot.identity). We don’t support reducers for the faceting, but for symmetry // with x and y we allow facets to be specified as {value} objects. +// TODO Normalize mark name to lowercase? function normalizeOptions({x, y, color, size, fx, fy, mark} = {}) { if (!isOptions(x)) x = makeOptions(x); if (!isOptions(y)) y = makeOptions(y); diff --git a/test/marks/auto-test.js b/test/marks/auto-test.js index 88ccb00036..c9a0a4b187 100644 --- a/test/marks/auto-test.js +++ b/test/marks/auto-test.js @@ -1,12 +1,25 @@ import * as Plot from "@observablehq/plot"; import assert from "assert"; +it("Plot.autoCompile compiles to explicit marks", () => { + const data = [{value: 1}, {value: 1}, {value: 38}]; + assert.strictEqual( + Plot.autoCompile(data, {x: "value"}), + `Plot.plot({ + marks: [ + Plot.rectY(data, Plot.binX({"y":"count"}, {"x":{"value":[1,1,38]}})), + Plot.ruleY([0]) + ] +})` + ); +}); + it("Plot.autoSpec makes a histogram from a quantitative dimension", () => { const data = [{value: 1}, {value: 1}, {value: 38}]; assert.deepStrictEqual(Plot.autoSpec(data, {x: "value"}), { fx: null, fy: null, - x: {value: "value", reduce: null}, + x: {value: "value", reduce: null, zero: false}, y: {value: null, reduce: "count", zero: true}, color: {value: null, reduce: null}, size: {value: null, reduce: null}, @@ -19,7 +32,7 @@ it("Plot.autoSpec makes a bar chart from an ordinal dimension", () => { assert.deepStrictEqual(Plot.autoSpec(data, {x: "value", color: "blue"}), { fx: null, fy: null, - x: {value: "value", reduce: null}, + x: {value: "value", reduce: null, zero: false}, y: {value: null, reduce: "count", zero: true}, color: {value: null, reduce: null, color: "blue"}, size: {value: null, reduce: null}, @@ -36,8 +49,8 @@ it("Plot.autoSpec makes a line from a monotonic dimension", () => { assert.deepStrictEqual(Plot.autoSpec(data, {x: "date", y: "value"}), { fx: null, fy: null, - x: {value: "date", reduce: null}, - y: {value: "value", reduce: null}, + x: {value: "date", reduce: null, zero: false}, + y: {value: "value", reduce: null, zero: false}, color: {value: null, reduce: null}, size: {value: null, reduce: null}, mark: "line" @@ -53,8 +66,8 @@ it("Plot.autoSpec makes a dot plot from two quantitative dimensions", () => { assert.deepStrictEqual(Plot.autoSpec(data, {x: "x", y: "y"}), { fx: null, fy: null, - x: {value: "x", reduce: null}, - y: {value: "y", reduce: null}, + x: {value: "x", reduce: null, zero: false}, + y: {value: "y", reduce: null, zero: false}, color: {value: null, reduce: null}, size: {value: null, reduce: null}, mark: "dot" @@ -73,8 +86,8 @@ it("Plot.autoSpec makes a faceted heatmap", () => { assert.deepStrictEqual(Plot.autoSpec(data, {x: "x", y: "y", fy: "f", color: "count"}), { fx: null, fy: "f", - x: {value: "x", reduce: null}, - y: {value: "y", reduce: null}, + x: {value: "x", reduce: null, zero: false}, + y: {value: "y", reduce: null, zero: false}, color: {value: null, reduce: "count"}, size: {value: null, reduce: null}, mark: "bar" diff --git a/test/output/autoBarNonZeroReducer.svg b/test/output/autoBarNonZeroReducer.svg new file mode 100644 index 0000000000..c8915a1b2c --- /dev/null +++ b/test/output/autoBarNonZeroReducer.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + 0.0 + 0.2 + 0.4 + 0.6 + 0.8 + 1.0 + 1.2 + 1.4 + 1.6 + 1.8 + + + ↑ height + + + + + + + + + + + + + + + 1955 + 1960 + 1965 + 1970 + 1975 + 1980 + 1985 + 1990 + 1995 + 2000 + + + date_of_birth → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/autoBarZero.svg b/test/output/autoBarZero.svg deleted file mode 100644 index 5b63abcdf7..0000000000 --- a/test/output/autoBarZero.svg +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - A - B - C - D - E - F - G - H - I - J - K - L - M - N - O - P - Q - R - S - T - U - V - W - X - Y - Z - - - letter - - - - - - - - - - - - 0.00 - 0.02 - 0.04 - 0.06 - 0.08 - 0.10 - 0.12 - - - frequency → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/autoDotZero.svg b/test/output/autoDotZero.svg new file mode 100644 index 0000000000..a1837e91d1 --- /dev/null +++ b/test/output/autoDotZero.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + + + letter + + + + + + + + + + + + 0.00 + 0.02 + 0.04 + 0.06 + 0.08 + 0.10 + 0.12 + + + frequency → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/autoBarMeanZero.svg b/test/output/autoLineMeanZero.svg similarity index 55% rename from test/output/autoBarMeanZero.svg rename to test/output/autoLineMeanZero.svg index 5e4b404754..09fed388cc 100644 --- a/test/output/autoBarMeanZero.svg +++ b/test/output/autoLineMeanZero.svg @@ -47,38 +47,19 @@ ↑ temp_max - - - - - + + + - 2012 - 2013 - 2014 - 2015 - 2016 - - - - - - - - - - - - - - - - - - + 2013 + 2014 + 2015 + + + \ No newline at end of file diff --git a/test/output/autoLineZero.svg b/test/output/autoLineZero.svg new file mode 100644 index 0000000000..911d370189 --- /dev/null +++ b/test/output/autoLineZero.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + 0 + 200 + 400 + 600 + 800 + 1,000 + 1,200 + 1,400 + 1,600 + 1,800 + 2,000 + 2,200 + 2,400 + + + ↑ unemployed + + + + + + + + + + + + + + + + 2000 + 2001 + 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + 2008 + 2009 + 2010 + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/autoplot.ts b/test/plots/autoplot.ts index 85d8195ae6..a5a63c169c 100644 --- a/test/plots/autoplot.ts +++ b/test/plots/autoplot.ts @@ -1,6 +1,18 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +// Tanner's bug https://github.com/observablehq/plot/issues/1365 +export async function autoLineZero() { + const industries = await d3.csv("data/bls-industry-unemployment.csv", d3.autoType); + return Plot.auto(industries, {x: "date", y: {value: "unemployed", zero: true}, color: "industry"}).plot(); +} + +// Jeff's bug https://github.com/observablehq/plot/issues/1340 +export async function autoBarNonZeroReducer() { + const athletes = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.auto(athletes, {x: "date_of_birth", y: {value: "height", reduce: "mean"}, mark: "bar"}).plot(); +} + export async function autoHistogram() { const athletes = await d3.csv("data/athletes.csv", d3.autoType); return Plot.auto(athletes, {x: "weight"}).plot(); @@ -81,14 +93,14 @@ export async function autoDotOrdCont() { return Plot.auto(penguins, {x: "culmen_length_mm", y: "species"}).plot(); } -export async function autoBar() { +export async function autoDotZero() { const alphabet = await d3.csv("data/alphabet.csv", d3.autoType); - return Plot.auto(alphabet, {x: "frequency", y: "letter", mark: "bar"}).plot(); + return Plot.auto(alphabet, {x: {value: "frequency", zero: true}, y: "letter"}).plot(); } -export async function autoBarZero() { +export async function autoBar() { const alphabet = await d3.csv("data/alphabet.csv", d3.autoType); - return Plot.auto(alphabet, {x: {value: "frequency", zero: true}, y: "letter"}).plot(); + return Plot.auto(alphabet, {x: "frequency", y: "letter", mark: "bar"}).plot(); } export async function autoConnectedScatterplot() { @@ -197,11 +209,6 @@ export async function autoBarStackColorConstant() { return Plot.auto(athletes, {x: "height", color: "gold"}).plot(); } -export async function autoBarMeanZero() { - const weather = await d3.csv("data/seattle-weather.csv", d3.autoType); - return Plot.auto(weather, {x: "date", y: {value: "temp_max", reduce: "mean", zero: true}}).plot(); -} - export async function autoBarMode() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.auto(penguins, {x: "island", y: {value: "species", reduce: "mode"}, mark: "bar"}).plot(); @@ -212,6 +219,11 @@ export async function autoLineMean() { return Plot.auto(weather, {x: "date", y: {value: "temp_max", reduce: "mean"}}).plot(); } +export async function autoLineMeanZero() { + const weather = await d3.csv("data/seattle-weather.csv", d3.autoType); + return Plot.auto(weather, {x: "date", y: {value: "temp_max", reduce: "mean", zero: true}}).plot(); +} + export async function autoLineMeanColor() { const athletes = await d3.csv("data/athletes.csv", d3.autoType); return Plot.auto(athletes, {x: "date_of_birth", y: {value: "height", reduce: "mean"}, color: "sex"}).plot();