diff --git a/docs/features/scales.md b/docs/features/scales.md
index c9a494b805..0cfd7ad22c 100644
--- a/docs/features/scales.md
+++ b/docs/features/scales.md
@@ -621,7 +621,7 @@ Plot.plot({
[Mark transforms](./transforms.md) typically consume values *before* they are passed through scales (_e.g._, when binning). In this case the mark transforms will see the values prior to the scale transform as input, and the scale transform will apply to the *output* of the mark transform.
:::
-The **interval** scale option sets an ordinal scale’s **domain** to the start of every interval within the extent of the data. In addition, it implicitly sets the **transform** of the scale to *interval*.floor, rounding values down to the start of each interval. For example, below we generate a time-series bar chart; when an **interval** is specified, missing days are visible.
+The **interval** scale option sets an ordinal scale’s **domain** to the start of every interval within the extent of the data. In addition, it implicitly sets the **transform** of the scale to *interval*.floor, rounding values down to the start of each interval. Set **transform** to null to disable this implicit rounding while still using the **interval** to align the domain. For example, below we generate a time-series bar chart; when an **interval** is specified, missing days are visible.
@@ -724,7 +724,7 @@ The default range depends on the scale: for position scales (*x*, *y*, *fx*, and
The behavior of the **unknown** scale 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.
-For data at regular intervals, such as integer values or daily samples, the [**interval** option](#scale-transforms) 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. The option can alternatively be specified as a string (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) naming the corresponding time interval, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. 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.
+For data at regular intervals, such as integer values or daily samples, the [**interval** option](#scale-transforms) 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. The option can alternatively be specified as a string (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) naming the corresponding time interval, or a skip interval consisting of a number followed by the interval name (possibly pluralized), such as *3 months* or *10 years*. This option sets the default *scale*.transform to the given interval’s *interval*.floor function, unless **transform** is set (including to null). 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:
diff --git a/src/scales.d.ts b/src/scales.d.ts
index f3bd753c4a..0d5032e264 100644
--- a/src/scales.d.ts
+++ b/src/scales.d.ts
@@ -435,9 +435,10 @@ export interface ScaleOptions extends ScaleDefaults {
* y: {transform: (y) => (y) / 1000}
* ```
*
- * See also the **percent** and **interval** shorthand options.
+ * See also the **percent** and **interval** shorthand options. If null,
+ * disables the implicit transform associated with those options.
*/
- transform?: (t: any) => any;
+ transform?: ((t: any) => any) | null;
/**
* Enforces uniformity for data at regular intervals, such as integer values
@@ -449,7 +450,8 @@ export interface ScaleOptions extends ScaleDefaults {
*
* This option sets the default **transform** to the given interval’s
* *interval*.floor function. In addition, the default **domain** will align
- * with interval boundaries.
+ * with interval boundaries. Set **transform** to null to disable the implicit
+ * transform while still using the interval to align the domain.
*/
interval?: RangeInterval;
diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js
index 29925d812e..b885c641c8 100644
--- a/test/scales/scales-test.js
+++ b/test/scales/scales-test.js
@@ -2098,6 +2098,21 @@ it("plot(…).scale(name) reflects the given transform", async () => {
});
});
+it("plot(…).scale(name) transform: null disables the implicit interval transform", () => {
+ const data = [1.2, 3.8];
+ const implicit = Plot.dotX(data, {x: (d) => d}).plot({x: {interval: 1}});
+ const disabled = Plot.dotX(data, {x: (d) => d}).plot({x: {interval: 1, transform: null}});
+ assert.deepStrictEqual(implicit.scale("x").domain, [1, 3]);
+ scaleEqual(disabled.scale("x"), {
+ type: "linear",
+ domain: [1.2, 3.8],
+ range: [20, 620],
+ clamp: false,
+ interpolate: d3.interpolateNumber,
+ interval: ["floor", "offset", "range"]
+ });
+});
+
it("plot(…).scale(name) can return an identity scale, ignoring all other options", () => {
const plot = Plot.dot([1, 2], {x: (d) => d, fill: (d) => d}).plot({x: {type: "identity"}, color: {type: "identity"}});
scaleEqual(plot.scale("x"), {type: "identity"});
diff --git a/test/types-test.js b/test/types-test.js
new file mode 100644
index 0000000000..a0bd8f5f4d
--- /dev/null
+++ b/test/types-test.js
@@ -0,0 +1,23 @@
+import {Project} from "ts-morph";
+import {it} from "vitest";
+import assert from "./assert.js";
+
+it("ScaleOptions.transform accepts null under strictNullChecks", () => {
+ const project = new Project({
+ tsConfigFilePath: "tsconfig.json",
+ skipAddingFilesFromTsConfig: true,
+ compilerOptions: {strictNullChecks: true, noEmit: true}
+ });
+ project.addSourceFilesAtPaths("src/**/*.d.ts");
+ project.createSourceFile(
+ "check-scale-transform-null.ts",
+ `import type {ScaleOptions} from "./src/scales.js";
+const options: ScaleOptions = {interval: "6 hours", transform: null};
+`
+ );
+ const diagnostics = project
+ .getPreEmitDiagnostics()
+ .filter((d) => d.getSourceFile()?.getFilePath().endsWith("check-scale-transform-null.ts"))
+ .map((d) => String(d.getMessageText()));
+ assert.deepStrictEqual(diagnostics, []);
+});