From b98f9be3142d86cebb72d6abd59ab70a2dd9df89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 17:06:22 +0200 Subject: [PATCH 01/15] Specifying a scale interval shows the intent of having ordinal numerical or ordinal dates: suppress warning. Side note: if a numeric interval was specified, string numerics have already been coerced to numbers by the scale transform; so this in fact only has consequences for ordinal dates, such as in the downloads-ordinal test plot. --- src/scales.js | 15 +- test/output/downloadsOrdinal.svg | 255 +++++++++++++++++++++++++++++++ test/plots/downloads-ordinal.js | 17 +++ test/plots/index.js | 1 + 4 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 test/output/downloadsOrdinal.svg create mode 100644 test/plots/downloads-ordinal.js diff --git a/src/scales.js b/src/scales.js index e7aab31a58..2a87d9ccf0 100644 --- a/src/scales.js +++ b/src/scales.js @@ -136,20 +136,21 @@ function Scale(key, channels = [], options = {}) { const type = inferScaleType(key, channels, options); // Warn for common misuses of implicit ordinal scales. We disable this test if - // you set the domain or range explicitly, since setting the domain or range - // (typically with a cardinality of more than two) is another indication that - // you intended for the scale to be ordinal; we also disable it for facet - // scales since these are always band scales. + // you specify a scale interval or if you set the domain or range explicitly, + // since setting the domain or range (typically with a cardinality of more than + // two) is another indication that you intended for the scale to be ordinal; we + // also disable it for facet scales since these are always band scales. if (options.type === undefined && options.domain === undefined && options.range === undefined + && options.interval === undefined && key !== "fx" && key !== "fy" && isOrdinalScale({type})) { const values = channels.map(({value}) => value).filter(value => value !== undefined); - if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); - else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); - else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); + if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); + else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); + else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); } options.type = type; // Mutates input! diff --git a/test/output/downloadsOrdinal.svg b/test/output/downloadsOrdinal.svg new file mode 100644 index 0000000000..cc328db5d2 --- /dev/null +++ b/test/output/downloadsOrdinal.svg @@ -0,0 +1,255 @@ + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + + + 14 + + + 16 + + + 18 + + + 20 + + + 22 + ↑ downloads + + + + Jan 01 + + + Jan 02 + + + Jan 03 + + + Jan 04 + + + Jan 05 + + + Jan 06 + + + Jan 07 + + + Jan 08 + + + Jan 09 + + + Jan 10 + + + Jan 11 + + + Jan 12 + + + Jan 13 + + + Jan 14 + + + Jan 15 + + + Jan 16 + + + Jan 17 + + + Jan 18 + + + Jan 19 + + + Jan 20 + + + Jan 21 + + + Jan 22 + + + Jan 23 + + + Jan 24 + + + Jan 25 + + + Jan 26 + + + Jan 27 + + + Jan 28 + + + Jan 29 + + + Jan 30 + + + Jan 31 + + + Feb 01 + + + Feb 02 + + + Feb 03 + + + Feb 04 + + + Feb 05 + + + Feb 06 + + + Feb 07 + + + Feb 08 + + + Feb 09 + + + Feb 10 + + + Feb 11 + + + Feb 12 + + + Feb 13 + + + Feb 14 + + + Feb 15 + date + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/downloads-ordinal.js b/test/plots/downloads-ordinal.js new file mode 100644 index 0000000000..5948bfbcf3 --- /dev/null +++ b/test/plots/downloads-ordinal.js @@ -0,0 +1,17 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const downloads = (await d3.csv("data/downloads.csv", d3.autoType)) + .filter(d => d.date.getUTCFullYear() === 2019 && d.date.getUTCMonth() <= 1 && d.downloads > 0); + return Plot.plot({ + width: 960, + marginBottom: 55, + x: {interval: d3.utcDay, tickRotate: -45, tickFormat: "%b %d"}, + marks: [ + Plot.barY(downloads, {x: "date", y: "downloads", fill: "#ccc"}), + Plot.tickY(downloads, {x: "date", y: "downloads"}), + Plot.ruleY([0]) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index a795ec1797..00e10e58f2 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -45,6 +45,7 @@ export {default as diamondsCaratPriceDots} from "./diamonds-carat-price-dots.js" export {default as diamondsCaratSampling} from "./diamonds-carat-sampling.js"; export {default as documentationLinks} from "./documentation-links.js"; export {default as downloads} from "./downloads.js"; +export {default as downloadsOrdinal} from "./downloads-ordinal.js"; export {default as driving} from "./driving.js"; export {default as empty} from "./empty.js"; export {default as emptyLegend} from "./empty-legend.js"; From ffd1f53593f00f793d8114754893a67a4d60ce17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 18:22:37 +0200 Subject: [PATCH 02/15] document scale intervals --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2deed0c1f7..313ba20672 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ A scale’s domain (the extent of its inputs, abstract values) and range (the ex * *scale*.**range** - typically [*min*, *max*], or an array of ordinal or categorical values * *scale*.**unknown** - the desired output value (defaults to undefined) for invalid input values * *scale*.**reverse** - reverses the domain (or in somes cases, the range), say to flip the chart along *x* or *y* +* *scale*.**interval** - an interval to create an array of ordinal values For most quantitative scales, the default domain is the [*min*, *max*] of all values associated with the scale. For the *radius* and *opacity* scales, the default domain is [0, *max*] to ensure a meaningful value encoding. For ordinal scales, the default domain is the set of all distinct values associated with the scale in natural ascending order; for a different order, set the domain explicitly or add a [sort option](#sort-options) to an associated mark. For threshold scales, the default domain is [0] to separate negative and non-negative values. For quantile scales, the default domain is the set of all defined values associated with the scale. If a scale is reversed, it is equivalent to setting the domain as [*max*, *min*] instead of [*min*, *max*]. @@ -192,6 +193,8 @@ The default range depends on the scale: for [position scales](#position-options) The behavior of the *scale*.unknown option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output. +The *scale*.interval option will create an ordinal quantized domain—an array of equally spaced values spanning the extent of the defined values associated with the scale. An interval—such as d3.utcMonth—must expose an *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) functions. The option can also be specified as a number, in which case it will be promoted to an interval with the given step. + Quantitative scales can be further customized with additional options: * *scale*.**clamp** - if true, clamp input values to the scale’s domain From e912814be63e5b14ecd49ec6e0492d07e6fd6c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 18:23:03 +0200 Subject: [PATCH 03/15] test plot with year intervals --- test/output/yearlyRequests.svg | 121 +++++++++++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/yearly-requests.js | 12 ++++ 3 files changed, 134 insertions(+) create mode 100644 test/output/yearlyRequests.svg create mode 100644 test/plots/yearly-requests.js diff --git a/test/output/yearlyRequests.svg b/test/output/yearlyRequests.svg new file mode 100644 index 0000000000..d76f454217 --- /dev/null +++ b/test/output/yearlyRequests.svg @@ -0,0 +1,121 @@ + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 2002 + + + 2003 + + + 2004 + + + 2005 + + + 2006 + + + 2007 + + + 2008 + + + 2009 + + + 2010 + + + 2011 + + + 2012 + + + 2013 + + + 2014 + + + 2015 + + + 2016 + + + 2017 + + + 2018 + + + 2019 + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 00e10e58f2..e3abeb116c 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -170,6 +170,7 @@ export {default as wealthBritainBar} from "./wealth-britain-bar.js"; export {default as wealthBritainProportionPlot} from "./wealth-britain-proportion-plot.js"; export {default as wordCloud} from "./word-cloud.js"; export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; +export {default as yearlyRequests} from "./yearly-requests.js"; export * from "./legend-color.js"; export * from "./legend-opacity.js"; diff --git a/test/plots/yearly-requests.js b/test/plots/yearly-requests.js new file mode 100644 index 0000000000..bfd7f7fcfa --- /dev/null +++ b/test/plots/yearly-requests.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; + return Plot.plot({ + x: {interval: 1, label: null}, + y: {label: null}, + marks: [ + Plot.barY(requests, {x: "0", y: "1", fill: "#ccc", stroke:"#333"}) + ] + }); +} From 7c74dbf6b37f5c7fcfef6c8f88e12131044328a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 20:06:46 +0200 Subject: [PATCH 04/15] Update src/scales.js Co-authored-by: Mike Bostock --- src/scales.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scales.js b/src/scales.js index 2a87d9ccf0..4629828474 100644 --- a/src/scales.js +++ b/src/scales.js @@ -143,7 +143,7 @@ function Scale(key, channels = [], options = {}) { if (options.type === undefined && options.domain === undefined && options.range === undefined - && options.interval === undefined + && options.interval == null && key !== "fx" && key !== "fy" && isOrdinalScale({type})) { From eb488ea4449c0ad6077dddddb7d7ed9c94a1c3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 20:07:26 +0200 Subject: [PATCH 05/15] Update src/scales.js Co-authored-by: Mike Bostock --- src/scales.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scales.js b/src/scales.js index 4629828474..279752f004 100644 --- a/src/scales.js +++ b/src/scales.js @@ -148,7 +148,7 @@ function Scale(key, channels = [], options = {}) { && key !== "fy" && isOrdinalScale({type})) { const values = channels.map(({value}) => value).filter(value => value !== undefined); - if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); + if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., d3.utcDay), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); } From 62e0035d11bda1bf87d982e0a5509594c86bd919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 20:07:41 +0200 Subject: [PATCH 06/15] Update src/scales.js Co-authored-by: Mike Bostock --- src/scales.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scales.js b/src/scales.js index 279752f004..8a53ee0c21 100644 --- a/src/scales.js +++ b/src/scales.js @@ -150,7 +150,7 @@ function Scale(key, channels = [], options = {}) { const values = channels.map(({value}) => value).filter(value => value !== undefined); if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., d3.utcDay), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); - else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); + else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., 1 for integers), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); } options.type = type; // Mutates input! From 26ee70befc5e5b3ec5f672742e87abb5311e6131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 19 Apr 2022 23:04:58 +0200 Subject: [PATCH 07/15] d3.utcDay-like intervals do not parse string dates --- src/scales.js | 2 +- test/output/ibmTrading.svg | 164 +++++++++++++++++++++++++++++++++++++ test/plots/ibm-trading.js | 25 ++++++ test/plots/index.js | 1 + 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/output/ibmTrading.svg create mode 100644 test/plots/ibm-trading.js diff --git a/src/scales.js b/src/scales.js index 8a53ee0c21..df5adb826d 100644 --- a/src/scales.js +++ b/src/scales.js @@ -149,7 +149,7 @@ function Scale(key, channels = [], options = {}) { && isOrdinalScale({type})) { const values = channels.map(({value}) => value).filter(value => value !== undefined); if (values.some(isTemporal)) warn(`Warning: some data associated with the ${key} scale are dates. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., d3.utcDay), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); - else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}" or by specifying the interval option.`); + else if (values.some(isTemporalString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be dates (e.g., YYYY-MM-DD). If these strings represent dates, you should parse them to Date objects. Dates are typically associated with a "utc" or "time" scale rather than a "${formatScaleType(type)}" scale. If you are using a bar mark, you probably want a rect mark with the interval option instead; if you are using a group transform, you probably want a bin transform instead. If you want to treat this data as ordinal, you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); else if (values.some(isNumericString)) warn(`Warning: some data associated with the ${key} scale are strings that appear to be numbers. If these strings represent numbers, you should parse or coerce them to numbers. Numbers are typically associated with a "linear" scale rather than a "${formatScaleType(type)}" scale. If you want to treat this data as ordinal, you can specify the interval of the ${key} scale (e.g., 1 for integers), or you can suppress this warning by setting the type of the ${key} scale to "${formatScaleType(type)}".`); } diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg new file mode 100644 index 0000000000..623a69aa01 --- /dev/null +++ b/test/output/ibmTrading.svg @@ -0,0 +1,164 @@ + + + + + + 0 + + + + 2 + + + + 4 + + + + 6 + + + + 8 + + + + 10 + + + + 12 + + + + 14 + + + + 16 + + + + 18 + + + + 20 + ↑ Volume (USD, millions) + + + + 2018-04-16 + + + 2018-04-17 + + + 2018-04-18 + + + 2018-04-19 + + + 2018-04-20 + + + 2018-04-21 + + + 2018-04-22 + + + 2018-04-23 + + + 2018-04-24 + + + 2018-04-25 + + + 2018-04-26 + + + 2018-04-27 + + + 2018-04-28 + + + 2018-04-29 + + + 2018-04-30 + + + 2018-05-01 + + + 2018-05-02 + + + 2018-05-03 + + + 2018-05-04 + + + 2018-05-05 + + + 2018-05-06 + + + 2018-05-07 + + + 2018-05-08 + + + 2018-05-09 + + + 2018-05-10 + + + 2018-05-11 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/ibm-trading.js b/test/plots/ibm-trading.js new file mode 100644 index 0000000000..e310413e1a --- /dev/null +++ b/test/plots/ibm-trading.js @@ -0,0 +1,25 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +// This example uses both an interval (to define an ordinal x-scale) and +// a custom transform to parse the dates from their string representation. +export default async function() { + const IBM = await d3.csv("data/ibm.csv").then(data => data.slice(-20)); + return Plot.plot({ + marginBottom: 65, + y: { + transform: d => d / 1e6, + label: "↑ Volume (USD, millions)", + grid: true + }, + x: { + interval: d3.utcDay, + transform: d => d3.utcDay.floor(d3.isoParse(d)), + tickRotate: -40, + label: null + }, + marks: [ + Plot.barY(IBM, {x: "Date", y: "Volume"}) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index e3abeb116c..4f7a38b8e1 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -70,6 +70,7 @@ export {default as groupedRects} from "./grouped-rects.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; export {default as highCardinalityOrdinal} from "./high-cardinality-ordinal.js"; export {default as identityScale} from "./identity-scale.js"; +export {default as ibmTrading} from "./ibm-trading.js"; export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; From 759c46f5d1a5ca821e86667afe37c0a3ee6f3a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 26 Apr 2022 14:24:13 -0700 Subject: [PATCH 08/15] reusable interval option --- src/scales.js | 2 ++ src/scales/ordinal.js | 2 +- src/scales/quantitative.js | 3 ++- test/scales/scales-test.js | 46 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/scales.js b/src/scales.js index df5adb826d..01894f909a 100644 --- a/src/scales.js +++ b/src/scales.js @@ -408,6 +408,7 @@ function exposeScale({ range, label, interpolate, + interval, transform, percent, pivot @@ -422,6 +423,7 @@ function exposeScale({ ...percent && {percent}, // only exposed if truthy ...label !== undefined && {label}, ...unknown !== undefined && {unknown}, + ...interval !== undefined && {interval}, // quantitative ...interpolate !== undefined && {interpolate}, diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 83bbbcd325..ea562e9908 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -28,7 +28,7 @@ export function ScaleO(scale, channels, { if (typeof range === "function") range = range(domain); scale.range(range); } - return {type, domain, range, scale, hint}; + return {type, domain, range, scale, hint, interval}; } export function ScaleOrdinal(key, channels, { diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 9c69c18dcb..ac6d170303 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -57,6 +57,7 @@ export function ScaleQ(key, scale, channels, { unknown, round, scheme, + interval, range = registry.get(key) === radius ? inferRadialRange(channels, domain) : registry.get(key) === length ? inferLengthRange(channels, domain) : registry.get(key) === opacity ? unit : undefined, interpolate = registry.get(key) === color ? (scheme == null && range !== undefined ? interpolateRgb : quantitativeScheme(scheme !== undefined ? scheme : type === "cyclical" ? "rainbow" : "turbo")) : round ? interpolateRound : interpolateNumber, reverse @@ -105,7 +106,7 @@ export function ScaleQ(key, scale, channels, { if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain(); if (range !== undefined) scale.range(range); if (clamp) scale.clamp(clamp); - return {type, domain, range, scale, interpolate}; + return {type, domain, range, scale, interpolate, interval}; } export function ScaleLinear(key, channels, options) { diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index b3bdef7259..18b4887590 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -1392,6 +1392,52 @@ it("plot(…).scale(name) reflects the given custom interpolator", async () => { }); }); +it("plot(…).scale(name).interval changes the domain and sets the transform option for ordinal scales", async () => { + const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; + const plot = Plot.barY(requests, {x: "0", y: "1"}).plot({x: {interval: 1}}); + scaleEqual(plot.scale("x"), { + align: 0.5, + bandwidth: 29, + domain: d3.range(2002, 2020), + interval: 1, + label: "0", + paddingInner: 0.1, + paddingOuter: 0.1, + range: [40, 620], + round: true, + step: 32, + type: "band" + }); +}); + +it("plot(…).scale(name).interval reflects the interval option for quantitative scales", async () => { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const plot = Plot.dotX(penguins, {x: "body_mass_g"}).plot({x: {interval: 50}}); + scaleEqual(plot.scale("x"), { + clamp: false, + domain: [2700, 6300], + interpolate: d3.interpolateNumber, + interval: 50, + label: "body_mass_g →", + range: [20, 620], + type: "linear" + }); +}); + +it("The interval option is reusable for ordinal scales", async () => { + const requests = [[2002,9],[2003.5,17],[2005.9,5]]; + const plot1 = Plot.barY(requests, {x: "0", y: "1"}).plot({x: {interval: 1}, className: "a"}); + const plot2 = Plot.barY(requests, {x: "0", y: "1"}).plot({x: plot1.scale("x"), className: "a"}); + assert.strictEqual(plot1.innerHTML, plot2.innerHTML); +}); + +it("The interval option is reusable for quantitative scales", async () => { + const requests = [[2002,9],[2003.5,17],[2005.9,5]]; + const plot1 = Plot.dot(requests, {x: "0", y: "1"}).plot({x: {interval: 1}, className: "a"}); + const plot2 = Plot.dot(requests, {x: "0", y: "1"}).plot({x: plot1.scale("x"), className: "a"}); + assert.strictEqual(plot1.innerHTML, plot2.innerHTML); +}); + it("plot(…).scale('color') allows a range to be specified in conjunction with a scheme", async () => { const gistemp = await d3.csv("data/gistemp.csv", d3.autoType); const plot = Plot.dot(gistemp, {x: "Date", fill: "Anomaly"}).plot({color: {range: [0, 0.5], scheme: "cool"}}); From fffe7363645e3b0023a60555453eeb675a2e91c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 26 Apr 2022 14:25:57 -0700 Subject: [PATCH 09/15] When the interval option is applied on a quantitative scale, generate the ticks with the interval; also set the tickFormat so that we don't show 1.0, 2.0, 3.0 if the interval is an integer. --- src/axes.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/axes.js b/src/axes.js index 70343a8047..db63244007 100644 --- a/src/axes.js +++ b/src/axes.js @@ -2,6 +2,7 @@ import {extent} from "d3"; import {AxisX, AxisY} from "./axis.js"; import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js"; import {position, registry} from "./scales/index.js"; +import {maybeInterval} from "./transforms/interval.js"; export function Axes( {x: xScale, y: yScale, fx: fxScale, fy: fyScale}, @@ -33,12 +34,22 @@ export function autoAxisTicks({x, y, fx, fy}, {x: xAxis, y: yAxis, fx: fxAxis, f } function autoAxisTicksK(scale, axis, k) { + tickInterval(scale, axis); if (axis.ticks === undefined) { const [min, max] = extent(scale.scale.range()); axis.ticks = (max - min) / k; } } +function tickInterval(scale, axis) { + const interval = maybeInterval(scale.interval); + if (interval != null) { + const [min, max] = extent(scale.scale.domain()); + if (axis.ticks === undefined) axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max))); + if (scale.type !== "point" && scale.type !== "band" && axis.tickFormat === undefined && typeof scale.interval === "number") axis.tickFormat = ","; + } +} + // Mutates axis.{label,labelAnchor,labelOffset} and scale.label! export function autoScaleLabels(channels, scales, {x, y, fx, fy}, dimensions, options) { if (fx) { From 2eeb68a28ccd464081149e0b5cda0829bc014d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 26 Apr 2022 14:26:04 -0700 Subject: [PATCH 10/15] tests --- test/output/yearlyRequestsDot.svg | 71 +++++++++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/yearly-requests-dot.js | 14 ++++++ 3 files changed, 86 insertions(+) create mode 100644 test/output/yearlyRequestsDot.svg create mode 100644 test/plots/yearly-requests-dot.js diff --git a/test/output/yearlyRequestsDot.svg b/test/output/yearlyRequestsDot.svg new file mode 100644 index 0000000000..6828f5ef89 --- /dev/null +++ b/test/output/yearlyRequestsDot.svg @@ -0,0 +1,71 @@ + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + + + 14 + + + 16 + + + + + + 2002 + + + + 2003 + + + + 2004 + + + + 2005 + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 4f7a38b8e1..427430e50c 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -172,6 +172,7 @@ export {default as wealthBritainProportionPlot} from "./wealth-britain-proportio export {default as wordCloud} from "./word-cloud.js"; export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; export {default as yearlyRequests} from "./yearly-requests.js"; +export {default as yearlyRequestsDot} from "./yearly-requests-dot.js"; export * from "./legend-color.js"; export * from "./legend-opacity.js"; diff --git a/test/plots/yearly-requests-dot.js b/test/plots/yearly-requests-dot.js new file mode 100644 index 0000000000..100fdb29d4 --- /dev/null +++ b/test/plots/yearly-requests-dot.js @@ -0,0 +1,14 @@ +import * as d3 from "d3"; +import * as Plot from "@observablehq/plot"; + +export default async function() { + const requests = [[new Date(2002, 0, 1), 9], [new Date(2003, 0, 1), 17], [new Date(2005, 0, 1), 5]]; + return Plot.plot({ + x: {type: "utc", interval: d3.utcYear, label: null, inset: 40, grid: true}, + y: {label: null, zero: true}, + marks: [ + Plot.ruleY([0]), + Plot.dot(requests, {fill: "#ccc", stroke:"#333"}) + ] + }); +} From d419c448f22594917abb8b377b0e9a20af872518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 9 Jun 2022 22:04:25 +0200 Subject: [PATCH 11/15] normalize intervals lists a few TODOs re: the default tick format: - we don't want decimal notation if the interval is specified as an integer - we don't want months to appear if the interval is specified as d3.utcYear - we don't want years to appear with commas (#768) --- src/axes.js | 15 ++-- src/scales.js | 2 +- src/scales/ordinal.js | 2 +- src/scales/quantitative.js | 3 +- test/output/integerInterval.svg | 62 ++++++++++++++++ test/output/yearlyRequestsLine.svg | 110 +++++++++++++++++++++++++++++ test/plots/index.js | 2 + test/plots/integer-interval.js | 14 ++++ test/plots/yearly-requests-dot.js | 5 +- test/plots/yearly-requests-line.js | 14 ++++ test/plots/yearly-requests.js | 3 +- test/scales/scales-test.js | 5 +- 12 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 test/output/integerInterval.svg create mode 100644 test/output/yearlyRequestsLine.svg create mode 100644 test/plots/integer-interval.js create mode 100644 test/plots/yearly-requests-line.js diff --git a/src/axes.js b/src/axes.js index db63244007..b91ea9169c 100644 --- a/src/axes.js +++ b/src/axes.js @@ -3,6 +3,7 @@ import {AxisX, AxisY} from "./axis.js"; import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js"; import {position, registry} from "./scales/index.js"; import {maybeInterval} from "./transforms/interval.js"; +import {formatDefault} from "./format.js"; export function Axes( {x: xScale, y: yScale, fx: fxScale, fy: fyScale}, @@ -41,12 +42,18 @@ function autoAxisTicksK(scale, axis, k) { } } +// Scales defined with an interval default to regular ticks. +// If the interval is specified as an integer, the tick format should not produce decimal dots. function tickInterval(scale, axis) { const interval = maybeInterval(scale.interval); - if (interval != null) { - const [min, max] = extent(scale.scale.domain()); - if (axis.ticks === undefined) axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max))); - if (scale.type !== "point" && scale.type !== "band" && axis.tickFormat === undefined && typeof scale.interval === "number") axis.tickFormat = ","; + if (interval !== undefined) { + if (axis.ticks === undefined) { + const [min, max] = extent(scale.scale.domain()); + axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max))); + } + if (axis.tickFormat === undefined) { + axis.tickFormat = formatDefault; + } } } diff --git a/src/scales.js b/src/scales.js index 01894f909a..b5dfa5f6ca 100644 --- a/src/scales.js +++ b/src/scales.js @@ -143,7 +143,7 @@ function Scale(key, channels = [], options = {}) { if (options.type === undefined && options.domain === undefined && options.range === undefined - && options.interval == null + && options.interval === undefined && key !== "fx" && key !== "fy" && isOrdinalScale({type})) { diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index ea562e9908..cf5aee59c1 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -28,7 +28,7 @@ export function ScaleO(scale, channels, { if (typeof range === "function") range = range(domain); scale.range(range); } - return {type, domain, range, scale, hint, interval}; + return {type, domain, range, scale, hint, interval: maybeInterval(interval)}; } export function ScaleOrdinal(key, channels, { diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index ac6d170303..c71d81bfcb 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -26,6 +26,7 @@ import { import {positive, negative, finite} from "../defined.js"; import {arrayify, constant, order} from "../options.js"; import {ordinalRange, quantitativeScheme} from "./schemes.js"; +import {maybeInterval} from "../transforms/interval.js"; import {registry, radius, opacity, color, length} from "./index.js"; export const flip = i => t => i(1 - t); @@ -106,7 +107,7 @@ export function ScaleQ(key, scale, channels, { if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain(); if (range !== undefined) scale.range(range); if (clamp) scale.clamp(clamp); - return {type, domain, range, scale, interpolate, interval}; + return {type, domain, range, scale, interpolate, interval: maybeInterval(interval)}; } export function ScaleLinear(key, channels, options) { diff --git a/test/output/integerInterval.svg b/test/output/integerInterval.svg new file mode 100644 index 0000000000..15d80e1467 --- /dev/null +++ b/test/output/integerInterval.svg @@ -0,0 +1,62 @@ + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + + + 14 + + + 16 + + + + + 2 + + + 3 + + + 4 + + + 5 + + + + + + \ No newline at end of file diff --git a/test/output/yearlyRequestsLine.svg b/test/output/yearlyRequestsLine.svg new file mode 100644 index 0000000000..6d2b0fe7c5 --- /dev/null +++ b/test/output/yearlyRequestsLine.svg @@ -0,0 +1,110 @@ + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + + + 12 + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 2002 + + + 2003 + + + 2004 + + + 2005 + + + 2006 + + + 2007 + + + 2008 + + + 2009 + + + 2010 + + + 2011 + + + 2012 + + + 2013 + + + 2014 + + + 2015 + + + 2016 + + + 2017 + + + 2018 + + + 2019 + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 427430e50c..ab4cd54445 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -70,6 +70,7 @@ export {default as groupedRects} from "./grouped-rects.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; export {default as highCardinalityOrdinal} from "./high-cardinality-ordinal.js"; export {default as identityScale} from "./identity-scale.js"; +export {default as integerInterval} from "./integer-interval.js"; export {default as ibmTrading} from "./ibm-trading.js"; export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; @@ -173,6 +174,7 @@ export {default as wordCloud} from "./word-cloud.js"; export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; export {default as yearlyRequests} from "./yearly-requests.js"; export {default as yearlyRequestsDot} from "./yearly-requests-dot.js"; +export {default as yearlyRequestsLine} from "./yearly-requests-line.js"; export * from "./legend-color.js"; export * from "./legend-opacity.js"; diff --git a/test/plots/integer-interval.js b/test/plots/integer-interval.js new file mode 100644 index 0000000000..a5def5f8af --- /dev/null +++ b/test/plots/integer-interval.js @@ -0,0 +1,14 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const requests = [[2,9],[3,17],[5,12]]; + return Plot.plot({ + // Since these numbers represent years, we want to format them without the comma + // TODO: this should be automatic, even for a continuous scale + x: {interval: 1, label: null, inset: 30}, + y: {label: null, zero: true}, + marks: [ + Plot.lineY(requests, {x: "0", y: "1"}) + ] + }); +} diff --git a/test/plots/yearly-requests-dot.js b/test/plots/yearly-requests-dot.js index 100fdb29d4..281b0d1d6e 100644 --- a/test/plots/yearly-requests-dot.js +++ b/test/plots/yearly-requests-dot.js @@ -2,9 +2,10 @@ import * as d3 from "d3"; import * as Plot from "@observablehq/plot"; export default async function() { - const requests = [[new Date(2002, 0, 1), 9], [new Date(2003, 0, 1), 17], [new Date(2005, 0, 1), 5]]; + const requests = [[new Date(Date.UTC(2002, 0, 1)), 9], [new Date(Date.UTC(2003, 0, 1)), 17], [new Date(Date.UTC(2005, 0, 1)), 5]]; return Plot.plot({ - x: {type: "utc", interval: d3.utcYear, label: null, inset: 40, grid: true}, + // TODO: the default tickFormat could be inferred from the interval + x: {type: "utc", interval: d3.utcYear, label: null, inset: 40, grid: true, tickFormat: "%Y"}, y: {label: null, zero: true}, marks: [ Plot.ruleY([0]), diff --git a/test/plots/yearly-requests-line.js b/test/plots/yearly-requests-line.js new file mode 100644 index 0000000000..69cd0046f5 --- /dev/null +++ b/test/plots/yearly-requests-line.js @@ -0,0 +1,14 @@ +import * as Plot from "@observablehq/plot"; + +export default async function() { + const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; + return Plot.plot({ + // Since these numbers represent years, we want to format them without the comma + // TODO: this should be automatic, even for a continuous scale + x: {interval: 1, label: null, inset: 20, tickFormat: ""}, + y: {label: null, zero: true}, + marks: [ + Plot.lineY(requests, {x: "0", y: "1"}) + ] + }); +} diff --git a/test/plots/yearly-requests.js b/test/plots/yearly-requests.js index bfd7f7fcfa..35c3a72c0a 100644 --- a/test/plots/yearly-requests.js +++ b/test/plots/yearly-requests.js @@ -3,7 +3,8 @@ import * as Plot from "@observablehq/plot"; export default async function() { const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; return Plot.plot({ - x: {interval: 1, label: null}, + // TODO ideally the default tickFormat would give "2002" (#768) + x: {interval: 1, label: null, tickFormat: ""}, y: {label: null}, marks: [ Plot.barY(requests, {x: "0", y: "1", fill: "#ccc", stroke:"#333"}) diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 18b4887590..e20b5244aa 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -1399,7 +1399,7 @@ it("plot(…).scale(name).interval changes the domain and sets the transform opt align: 0.5, bandwidth: 29, domain: d3.range(2002, 2020), - interval: 1, + interval: ["floor", "offset", "range"], label: "0", paddingInner: 0.1, paddingOuter: 0.1, @@ -1417,7 +1417,7 @@ it("plot(…).scale(name).interval reflects the interval option for quantitative clamp: false, domain: [2700, 6300], interpolate: d3.interpolateNumber, - interval: 50, + interval: ["floor", "offset", "range"], label: "body_mass_g →", range: [20, 620], type: "linear" @@ -1530,6 +1530,7 @@ function scaleEqual({...scale}, spec) { } else { delete scale.apply; } + if (scale.interval) scale.interval = Object.keys(scale.interval); if (typeof scale.invert !== "function" && !(["band", "point", "threshold", "ordinal", "diverging", "diverging-log", "diverging-symlog", "diverging-pow" ].includes(scale.type))) { scale.invert = typeof scale.invert; } else { From dc791050677aa8f6d9b35216ddbc8bde1c7c99cd Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 10 Jun 2022 08:04:39 -0700 Subject: [PATCH 12/15] formatDefault for ordinal scales --- src/axes.js | 27 +++++++++++---------------- test/output/integerInterval.svg | 18 +++++++++--------- test/plots/integer-interval.js | 14 ++++++++------ 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/axes.js b/src/axes.js index b91ea9169c..e0a7a3b5f0 100644 --- a/src/axes.js +++ b/src/axes.js @@ -2,7 +2,6 @@ import {extent} from "d3"; import {AxisX, AxisY} from "./axis.js"; import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js"; import {position, registry} from "./scales/index.js"; -import {maybeInterval} from "./transforms/interval.js"; import {formatDefault} from "./format.js"; export function Axes( @@ -35,25 +34,21 @@ export function autoAxisTicks({x, y, fx, fy}, {x: xAxis, y: yAxis, fx: fxAxis, f } function autoAxisTicksK(scale, axis, k) { - tickInterval(scale, axis); if (axis.ticks === undefined) { - const [min, max] = extent(scale.scale.range()); - axis.ticks = (max - min) / k; - } -} - -// Scales defined with an interval default to regular ticks. -// If the interval is specified as an integer, the tick format should not produce decimal dots. -function tickInterval(scale, axis) { - const interval = maybeInterval(scale.interval); - if (interval !== undefined) { - if (axis.ticks === undefined) { + const interval = scale.interval; + if (interval !== undefined) { const [min, max] = extent(scale.scale.domain()); axis.ticks = interval.range(interval.floor(min), interval.offset(interval.floor(max))); + } else { + const [min, max] = extent(scale.scale.range()); + axis.ticks = (max - min) / k; } - if (axis.tickFormat === undefined) { - axis.tickFormat = formatDefault; - } + } + // D3’s ordinal scales simply use toString by default, but if the ordinal + // scale domain (or ticks) are numbers or dates (say because we’re applying a + // time interval to the ordinal scale), we want Plot’s default formatter. + if (axis.tickFormat === undefined && isOrdinalScale(scale)) { + axis.tickFormat = formatDefault; } } diff --git a/test/output/integerInterval.svg b/test/output/integerInterval.svg index 15d80e1467..b551bd339e 100644 --- a/test/output/integerInterval.svg +++ b/test/output/integerInterval.svg @@ -43,20 +43,20 @@ - - 2 + + 2.0 - - 3 + + 3.0 - - 4 + + 4.0 - - 5 + + 5.0 - + \ No newline at end of file diff --git a/test/plots/integer-interval.js b/test/plots/integer-interval.js index a5def5f8af..e53b1a3744 100644 --- a/test/plots/integer-interval.js +++ b/test/plots/integer-interval.js @@ -1,14 +1,16 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const requests = [[2,9],[3,17],[5,12]]; + const requests = [[2, 9], [3, 17], [3.5, 10], [5, 12]]; return Plot.plot({ - // Since these numbers represent years, we want to format them without the comma - // TODO: this should be automatic, even for a continuous scale - x: {interval: 1, label: null, inset: 30}, - y: {label: null, zero: true}, + x: { + interval: 1 + }, + y: { + zero: true + }, marks: [ - Plot.lineY(requests, {x: "0", y: "1"}) + Plot.line(requests) ] }); } From bd0052164ef2b2a57e60100ad105ea8703ac210a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 10 Jun 2022 08:13:20 -0700 Subject: [PATCH 13/15] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 313ba20672..1e41dce570 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,9 @@ For most quantitative scales, the default domain is the [*min*, *max*] of all va The default range depends on the scale: for [position scales](#position-options) (*x*, *y*, *fx*, and *fy*), the default range depends on the plot’s [size and margins](#layout-options). For [color scales](#color-options), there are default color schemes for quantitative, ordinal, and categorical data. For opacity, the default range is [0, 1]. And for radius, the default range is designed to produce dots of “reasonable” size assuming a *sqrt* scale type for accurate area representation: zero maps to zero, the first quartile maps to a radius of three pixels, and other values are extrapolated. This convention for radius ensures that if the scale’s data values are all equal, dots have the default constant radius of three pixels, while if the data varies, dots will tend to be larger. -The behavior of the *scale*.unknown option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output. +The behavior of the *scale*.**unknown** option depends on the scale type. For quantitative and temporal scales, the unknown value is used whenever the input value is undefined, null, or NaN. For ordinal or categorical scales, the unknown value is returned for any input value outside the domain. For band or point scales, the unknown option has no effect; it is effectively always equal to undefined. If the unknown option is set to undefined (the default), or null or NaN, then the affected input values will be considered undefined and filtered from the output. -The *scale*.interval option will create an ordinal quantized domain—an array of equally spaced values spanning the extent of the defined values associated with the scale. An interval—such as d3.utcMonth—must expose an *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) functions. The option can also be specified as a number, in which case it will be promoted to an interval with the given step. +For data at regular intervals, such as integer values or daily samples, the *scale*.**interval** option can be used to enforce uniformity. The specified *interval*—such as d3.utcMonth—must expose an *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) functions. The option can also be specified as a number, in which case it will be promoted to a numeric interval with the given step. This option sets the default *scale*.transform to the given interval’s *interval*.floor function. In addition, the default *scale*.domain is an array of uniformly-spaced values spanning the extent of the values associated with the scale. Quantitative scales can be further customized with additional options: From 4e77cf4208743df5d0dcb16b945ded677a5aa781 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 10 Jun 2022 08:17:38 -0700 Subject: [PATCH 14/15] call maybeInterval sooner --- src/scales/ordinal.js | 12 ++++++++---- src/scales/quantitative.js | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index cf5aee59c1..35880fdf9f 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -15,11 +15,13 @@ export const ordinalImplicit = Symbol("ordinal"); export function ScaleO(scale, channels, { type, interval, - domain = inferDomain(channels, interval), + domain, range, reverse, hint }) { + interval = maybeInterval(interval); + if (domain === undefined) domain = inferDomain(channels, interval); if (type === "categorical" || type === ordinalImplicit) type = "ordinal"; // shorthand for color schemes if (reverse) domain = reverseof(domain); scale.domain(domain); @@ -28,18 +30,20 @@ export function ScaleO(scale, channels, { if (typeof range === "function") range = range(domain); scale.range(range); } - return {type, domain, range, scale, hint, interval: maybeInterval(interval)}; + return {type, domain, range, scale, hint, interval}; } export function ScaleOrdinal(key, channels, { type, interval, - domain = inferDomain(channels, interval), + domain, range, scheme, unknown, ...options }) { + interval = maybeInterval(interval); + if (domain === undefined) domain = inferDomain(channels, interval); let hint; if (registry.get(key) === symbol) { hint = inferSymbolHint(channels); @@ -112,7 +116,7 @@ function inferDomain(channels, interval) { if (value === undefined) continue; for (const v of value) values.add(v); } - if ((interval = maybeInterval(interval)) != null) { + if (interval !== undefined) { const [min, max] = extent(values).map(interval.floor, interval); return interval.range(min, interval.offset(max)); } diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index c71d81bfcb..75fe3e0899 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -63,6 +63,7 @@ export function ScaleQ(key, scale, channels, { interpolate = registry.get(key) === color ? (scheme == null && range !== undefined ? interpolateRgb : quantitativeScheme(scheme !== undefined ? scheme : type === "cyclical" ? "rainbow" : "turbo")) : round ? interpolateRound : interpolateNumber, reverse }) { + interval = maybeInterval(interval); if (type === "cyclical" || type === "sequential") type = "linear"; // shorthand for color schemes reverse = !!reverse; @@ -107,7 +108,7 @@ export function ScaleQ(key, scale, channels, { if (nice) scale.nice(nice === true ? undefined : nice), domain = scale.domain(); if (range !== undefined) scale.range(range); if (clamp) scale.clamp(clamp); - return {type, domain, range, scale, interpolate, interval: maybeInterval(interval)}; + return {type, domain, range, scale, interpolate, interval}; } export function ScaleLinear(key, channels, options) { From 7c565d9a419f7b80aa7ec9cd46a7bd5d0c66b926 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 10 Jun 2022 08:30:49 -0700 Subject: [PATCH 15/15] =?UTF-8?q?tabular-nums=20for=20interval=E2=80=99d?= =?UTF-8?q?=20ordinal=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/axes.js | 6 +- test/output/downloadsOrdinal.svg | 94 +++++++++++++++--------------- test/output/ibmTrading.svg | 2 +- test/output/sparseCell.svg | 4 +- test/output/yearlyRequests.svg | 4 +- test/output/yearlyRequestsDot.svg | 2 +- test/plots/downloads-ordinal.js | 6 +- test/plots/ibm-trading.js | 20 ++++--- test/plots/yearly-requests-dot.js | 21 +++++-- test/plots/yearly-requests-line.js | 30 ++++++++-- test/plots/yearly-requests.js | 25 ++++++-- 11 files changed, 132 insertions(+), 82 deletions(-) diff --git a/src/axes.js b/src/axes.js index e0a7a3b5f0..7a32af7268 100644 --- a/src/axes.js +++ b/src/axes.js @@ -19,8 +19,8 @@ export function Axes( return { ...xAxis && {x: new AxisX({grid, line, label, fontVariant: inferFontVariant(xScale), ...x, axis: xAxis})}, ...yAxis && {y: new AxisY({grid, line, label, fontVariant: inferFontVariant(yScale), ...y, axis: yAxis})}, - ...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, label: facetLabel, ...fx, axis: fxAxis})}, - ...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, label: facetLabel, ...fy, axis: fyAxis})} + ...fxAxis && {fx: new AxisX({name: "fx", grid: facetGrid, label: facetLabel, fontVariant: inferFontVariant(fxScale), ...fx, axis: fxAxis})}, + ...fyAxis && {fy: new AxisY({name: "fy", grid: facetGrid, label: facetLabel, fontVariant: inferFontVariant(fyScale), ...fy, axis: fyAxis})} }; } @@ -157,5 +157,5 @@ function inferLabel(channels = [], scale, axis, key) { } export function inferFontVariant(scale) { - return isOrdinalScale(scale) ? undefined : "tabular-nums"; + return isOrdinalScale(scale) && scale.interval === undefined ? undefined : "tabular-nums"; } diff --git a/test/output/downloadsOrdinal.svg b/test/output/downloadsOrdinal.svg index cc328db5d2..9c45a66d42 100644 --- a/test/output/downloadsOrdinal.svg +++ b/test/output/downloadsOrdinal.svg @@ -51,144 +51,144 @@ 22 ↑ downloads - + - Jan 01 + Jan 01 - Jan 02 + Jan 02 - Jan 03 + Jan 03 - Jan 04 + Jan 04 - Jan 05 + Jan 05 - Jan 06 + Jan 06 - Jan 07 + Jan 07 - Jan 08 + Jan 08 - Jan 09 + Jan 09 - Jan 10 + Jan 10 - Jan 11 + Jan 11 - Jan 12 + Jan 12 - Jan 13 + Jan 13 - Jan 14 + Jan 14 - Jan 15 + Jan 15 - Jan 16 + Jan 16 - Jan 17 + Jan 17 - Jan 18 + Jan 18 - Jan 19 + Jan 19 - Jan 20 + Jan 20 - Jan 21 + Jan 21 - Jan 22 + Jan 22 - Jan 23 + Jan 23 - Jan 24 + Jan 24 - Jan 25 + Jan 25 - Jan 26 + Jan 26 - Jan 27 + Jan 27 - Jan 28 + Jan 28 - Jan 29 + Jan 29 - Jan 30 + Jan 30 - Jan 31 + Jan 31 - Feb 01 + Feb 01 - Feb 02 + Feb 02 - Feb 03 + Feb 03 - Feb 04 + Feb 04 - Feb 05 + Feb 05 - Feb 06 + Feb 06 - Feb 07 + Feb 07 - Feb 08 + Feb 08 - Feb 09 + Feb 09 - Feb 10 + Feb 10 - Feb 11 + Feb 11 - Feb 12 + Feb 12 - Feb 13 + Feb 13 - Feb 14 + Feb 14 - Feb 15 + Feb 15 date diff --git a/test/output/ibmTrading.svg b/test/output/ibmTrading.svg index 623a69aa01..d94be4617e 100644 --- a/test/output/ibmTrading.svg +++ b/test/output/ibmTrading.svg @@ -59,7 +59,7 @@ 20 ↑ Volume (USD, millions) - + 2018-04-16 diff --git a/test/output/sparseCell.svg b/test/output/sparseCell.svg index 7c6a23bc3b..d936c08ff2 100644 --- a/test/output/sparseCell.svg +++ b/test/output/sparseCell.svg @@ -13,7 +13,7 @@ white-space: pre; } - + 1 @@ -127,7 +127,7 @@ 28 Season - + 1 diff --git a/test/output/yearlyRequests.svg b/test/output/yearlyRequests.svg index d76f454217..7d90b7d893 100644 --- a/test/output/yearlyRequests.svg +++ b/test/output/yearlyRequests.svg @@ -48,7 +48,7 @@ 20 - + 2002 @@ -104,7 +104,7 @@ 2019 - + diff --git a/test/output/yearlyRequestsDot.svg b/test/output/yearlyRequestsDot.svg index 6828f5ef89..b10b3d1816 100644 --- a/test/output/yearlyRequestsDot.svg +++ b/test/output/yearlyRequestsDot.svg @@ -63,7 +63,7 @@ - + diff --git a/test/plots/downloads-ordinal.js b/test/plots/downloads-ordinal.js index 5948bfbcf3..f2491f51a0 100644 --- a/test/plots/downloads-ordinal.js +++ b/test/plots/downloads-ordinal.js @@ -7,7 +7,11 @@ export default async function() { return Plot.plot({ width: 960, marginBottom: 55, - x: {interval: d3.utcDay, tickRotate: -45, tickFormat: "%b %d"}, + x: { + interval: d3.utcDay, + tickRotate: -90, + tickFormat: "%b %d" + }, marks: [ Plot.barY(downloads, {x: "date", y: "downloads", fill: "#ccc"}), Plot.tickY(downloads, {x: "date", y: "downloads"}), diff --git a/test/plots/ibm-trading.js b/test/plots/ibm-trading.js index e310413e1a..6d1cffc6dd 100644 --- a/test/plots/ibm-trading.js +++ b/test/plots/ibm-trading.js @@ -1,25 +1,27 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; -// This example uses both an interval (to define an ordinal x-scale) and -// a custom transform to parse the dates from their string representation. +// This example uses both an interval (to define an ordinal x-scale) and a +// custom transform to parse the dates from their string representation. This is +// not a recommended pattern: you should instead parse strings to dates when +// loading the data, say by applying d3.autoType or calling array.map. export default async function() { - const IBM = await d3.csv("data/ibm.csv").then(data => data.slice(-20)); + const ibm = await d3.csv("data/ibm.csv").then(data => data.slice(-20)); return Plot.plot({ marginBottom: 65, - y: { - transform: d => d / 1e6, - label: "↑ Volume (USD, millions)", - grid: true - }, x: { interval: d3.utcDay, transform: d => d3.utcDay.floor(d3.isoParse(d)), tickRotate: -40, label: null }, + y: { + transform: d => d / 1e6, + label: "↑ Volume (USD, millions)", + grid: true + }, marks: [ - Plot.barY(IBM, {x: "Date", y: "Volume"}) + Plot.barY(ibm, {x: "Date", y: "Volume"}) ] }); } diff --git a/test/plots/yearly-requests-dot.js b/test/plots/yearly-requests-dot.js index 281b0d1d6e..02dadf3a93 100644 --- a/test/plots/yearly-requests-dot.js +++ b/test/plots/yearly-requests-dot.js @@ -2,14 +2,25 @@ import * as d3 from "d3"; import * as Plot from "@observablehq/plot"; export default async function() { - const requests = [[new Date(Date.UTC(2002, 0, 1)), 9], [new Date(Date.UTC(2003, 0, 1)), 17], [new Date(Date.UTC(2005, 0, 1)), 5]]; + const requests = [ + [new Date("2002-1-01"), 9], + [new Date("2003-1-01"), 17], + [new Date("2005-1-01"), 5] + ]; return Plot.plot({ - // TODO: the default tickFormat could be inferred from the interval - x: {type: "utc", interval: d3.utcYear, label: null, inset: 40, grid: true, tickFormat: "%Y"}, - y: {label: null, zero: true}, + label: null, + x: { + type: "utc", + interval: d3.utcYear, + inset: 40, + grid: true + }, + y: { + zero: true + }, marks: [ Plot.ruleY([0]), - Plot.dot(requests, {fill: "#ccc", stroke:"#333"}) + Plot.dot(requests) ] }); } diff --git a/test/plots/yearly-requests-line.js b/test/plots/yearly-requests-line.js index 69cd0046f5..f59a501baf 100644 --- a/test/plots/yearly-requests-line.js +++ b/test/plots/yearly-requests-line.js @@ -1,14 +1,32 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; + const requests = [ + [2002, 9], + [2003, 17], + [2004, 12], + [2005, 5], + [2006, 12], + [2007, 18], + [2008, 16], + [2009, 11], + [2010, 9], + [2011, 8], + [2012, 9], + [2019, 20] + ]; return Plot.plot({ - // Since these numbers represent years, we want to format them without the comma - // TODO: this should be automatic, even for a continuous scale - x: {interval: 1, label: null, inset: 20, tickFormat: ""}, - y: {label: null, zero: true}, + label: null, + x: { + interval: 1, + tickFormat: "", // TODO https://github.com/observablehq/plot/issues/768 + inset: 20 + }, + y: { + zero: true + }, marks: [ - Plot.lineY(requests, {x: "0", y: "1"}) + Plot.line(requests) ] }); } diff --git a/test/plots/yearly-requests.js b/test/plots/yearly-requests.js index 35c3a72c0a..a3c5ed6084 100644 --- a/test/plots/yearly-requests.js +++ b/test/plots/yearly-requests.js @@ -1,13 +1,28 @@ import * as Plot from "@observablehq/plot"; export default async function() { - const requests = [[2002,9],[2003,17],[2004,12],[2005,5],[2006,12],[2007,18],[2008,16],[2009,11],[2010,9],[2011,8],[2012,9],[2019,20]]; + const requests = [ + [2002, 9], + [2003, 17], + [2004, 12], + [2005, 5], + [2006, 12], + [2007, 18], + [2008, 16], + [2009, 11], + [2010, 9], + [2011, 8], + [2012, 9], + [2019, 20] + ]; return Plot.plot({ - // TODO ideally the default tickFormat would give "2002" (#768) - x: {interval: 1, label: null, tickFormat: ""}, - y: {label: null}, + label: null, + x: { + interval: 1, + tickFormat: "" // TODO https://github.com/observablehq/plot/issues/768 + }, marks: [ - Plot.barY(requests, {x: "0", y: "1", fill: "#ccc", stroke:"#333"}) + Plot.barY(requests, {x: "0", y: "1"}) ] }); }