From c700061bcc19b86b763b1db6ab123c60e4cb6080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 23 Feb 2026 20:12:49 +0100 Subject: [PATCH 1/3] a default tickFormat for the symlog scale symlog scales typically span several orders of magnitude --- src/symlog.js | 15 ++++++++++++++- test/symlog-test.js | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/symlog.js b/src/symlog.js index 125fa7b..a1881a4 100644 --- a/src/symlog.js +++ b/src/symlog.js @@ -1,3 +1,4 @@ +import {format, formatSpecifier} from "d3-format"; import {linearish} from "./linear.js"; import {copy, transformer} from "./continuous.js"; import {initRange} from "./init.js"; @@ -21,7 +22,19 @@ export function symlogish(transform) { return arguments.length ? transform(transformSymlog(c = +_), transformSymexp(c)) : c; }; - return linearish(scale); + linearish(scale); + + scale.tickFormat = function(count, specifier) { + if (specifier == null) specifier = "s"; + if (typeof specifier !== "function") { + specifier = formatSpecifier(specifier); + if (specifier.precision == null) specifier.trim = true; + specifier = format(specifier); + } + return specifier; + }; + + return scale; } export default function symlog() { diff --git a/test/symlog-test.js b/test/symlog-test.js index 7b65749..1fb9e5e 100644 --- a/test/symlog-test.js +++ b/test/symlog-test.js @@ -168,3 +168,25 @@ it("symlog().clamp(true).invert(x) cannot return a value outside the domain", () assert.strictEqual(x.invert(0), 1); assert.strictEqual(x.invert(1), 20); }); + +it("symlog.tickFormat() defaults to SI prefix format, computed per tick", () => { + const s = scaleSymlog().domain([0, 1e6]); + const f = s.tickFormat(); + assert.strictEqual(f(0), "0"); + assert.strictEqual(f(0.01), "10m"); + assert.strictEqual(f(0.1), "100m"); + assert.strictEqual(f(1000), "1k"); + assert.strictEqual(f(500000), "500k"); + assert.strictEqual(f(1e6), "1M"); +}); + +it("symlog.tickFormat(count, specifier) accepts a format specifier", () => { + const s = scaleSymlog().domain([0, 100]); + assert.strictEqual(s.tickFormat(10, ",.0f")(50), "50"); + assert.strictEqual(s.tickFormat(10, "+f")(50), "+50"); +}); + +it("symlog.tickFormat(count, specifier) accepts a function specifier", () => { + const s = scaleSymlog().domain([0, 100]); + assert.strictEqual(s.tickFormat(10, (x) => `${x}a`)(42), "42a"); +}); From 78d7bc34175974d3cccac864c290508b82f0f782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 23 Feb 2026 22:33:33 +0100 Subject: [PATCH 2/3] nice symlog ticks --- src/symlog.js | 40 ++++++++++++++++++++++++++- test/symlog-test.js | 67 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/symlog.js b/src/symlog.js index a1881a4..b284af5 100644 --- a/src/symlog.js +++ b/src/symlog.js @@ -1,3 +1,4 @@ +import {ascending, descending, tickStep} from "d3-array"; import {format, formatSpecifier} from "d3-format"; import {linearish} from "./linear.js"; import {copy, transformer} from "./continuous.js"; @@ -24,7 +25,28 @@ export function symlogish(transform) { linearish(scale); - scale.tickFormat = function(count, specifier) { + scale.ticks = function (count) { + const domain = scale.domain(); + const [min, max] = scale.range().sort(ascending); + const n = count == null ? 10 : +count; + const extent = max - min; + if (n <= 0 || !extent || !isFinite(extent)) return []; + let ticks; + if (domain[0] * domain[1] > 0) { + ticks = niceTicks(scale, min, max, n, extent); + } else { + const origin = scale(0); + const n0 = Math.round((n * (origin - min)) / extent); + ticks = [ + ...niceTicks(scale, min, origin, n0, extent), + 0, + ...niceTicks(scale, origin, max, n - n0, extent), + ]; + } + return ticks.sort(domain[0] < domain[1] ? ascending : descending); + }; + + scale.tickFormat = function (_count, specifier) { if (specifier == null) specifier = "s"; if (typeof specifier !== "function") { specifier = formatSpecifier(specifier); @@ -37,6 +59,22 @@ export function symlogish(transform) { return scale; } +function niceTicks(scale, start, stop, n, span) { + if (!n || stop === start) return []; + const spacing = (stop - start) / n; + const h = Math.min(spacing / 2, span / 20); // cap to avoid degenerate rounding at low counts + const ticks = new Set(); + for (let i = 0; i <= n; ++i) { + const pi = start + i * spacing; + const v = scale.invert(pi); + const step = Math.abs(scale.invert(pi + h) - scale.invert(pi - h)); + const s = tickStep(0, step / 2, 2); + ticks.add(s ? Math.round(v / s) * s : v); + } + ticks.delete(0); + return [...ticks]; +} + export default function symlog() { var scale = symlogish(transformer()); diff --git a/test/symlog-test.js b/test/symlog-test.js index 1fb9e5e..e42169b 100644 --- a/test/symlog-test.js +++ b/test/symlog-test.js @@ -169,6 +169,73 @@ it("symlog().clamp(true).invert(x) cannot return a value outside the domain", () assert.strictEqual(x.invert(1), 20); }); +it("symlog.ticks() generates nice ticks across orders of magnitude", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(10), [0, 2, 15, 60, 300, 1000, 4000, 15000, 60000, 300000, 1000000]); +}); + +it("symlog.ticks() respects the requested count", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(5), [0, 15, 300, 4000, 60000, 1000000]); + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(20), [0, 1, 3, 7, 14, 30, 60, 120, 250, 500, 1000, 2000, 4000, 8000, 16000, 30000, 60000, 120000, 250000, 500000, 1000000]); +}); + +it("symlog.ticks() handles low counts", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(1), [0, 1000000]); + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(2), [0, 1000, 1000000]); + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(3), [0, 100, 10000, 1000000]); +}); + +it("symlog.ticks() generates symmetric ticks for symmetric domains", () => { + const t = scaleSymlog().domain([-1e6, 1e6]).ticks(10); + assert.deepStrictEqual(t, [-1000000, -50000, -5000, -200, -20, 0, 20, 200, 5000, 50000, 1000000]); +}); + +it("symlog.ticks() generates symmetric ticks for smaller domains", () => { + assert.deepStrictEqual(scaleSymlog().domain([-100, 100]).ticks(10), [-100, -40, -15, -6, -1.5, 0, 1.5, 6, 15, 40, 100]); +}); + +it("symlog.ticks() is independent of the range", () => { + const a = scaleSymlog().domain([0, 1e6]).range([0, 1]).ticks(10); + const b = scaleSymlog().domain([0, 1e6]).range([0, 640]).ticks(10); + assert.deepStrictEqual(a, b); +}); + +it("symlog.ticks() handles reversed domains", () => { + assert.deepStrictEqual(scaleSymlog().domain([1e6, 0]).ticks(10), [1000000, 300000, 60000, 15000, 4000, 1000, 300, 60, 15, 2, 0]); +}); + +it("symlog.ticks() adapts to the constant", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).constant(1000).ticks(10), [0, 1000, 3000, 7000, 14000, 30000, 60000, 120000, 250000, 500000, 1000000]); +}); + +it("symlog.ticks() always includes zero when it is in the domain", () => { + assert.ok(scaleSymlog().domain([0, 1e6]).ticks(10).includes(0)); + assert.ok(scaleSymlog().domain([-1e6, 0]).ticks(10).includes(0)); + assert.ok(scaleSymlog().domain([-1e6, 1e6]).ticks(10).includes(0)); + assert.ok(scaleSymlog().domain([-1e6, 1e3]).ticks(10).includes(0)); +}); + +it("symlog.ticks() allocates ticks proportionally for asymmetric domains", () => { + assert.deepStrictEqual(scaleSymlog().domain([-1e6, 1e3]).ticks(10), [-1000000, -100000, -20000, -2000, -400, -60, -5, 0, 10, 100, 1000]); +}); + +it("symlog.ticks() works when zero is not in the domain", () => { + assert.deepStrictEqual(scaleSymlog().domain([100, 1e6]).ticks(10), [100, 250, 600, 1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000]); + assert.deepStrictEqual(scaleSymlog().domain([1e6, 100]).ticks(10), [1000000, 400000, 150000, 60000, 25000, 10000, 4000, 1500, 600, 250, 100]); +}); + +it("symlog.ticks() handles edge cases", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 1e6]).ticks(0), []); + assert.deepStrictEqual(scaleSymlog().domain([5, 5]).ticks(10), [5]); +}); + +it("symlog.ticks() on a copy is isolated", () => { + const s1 = scaleSymlog().domain([0, 1e6]); + const s2 = s1.copy(); + assert.deepStrictEqual(s2.ticks(10), [0, 2, 15, 60, 300, 1000, 4000, 15000, 60000, 300000, 1000000]); + s1.domain([0, 100]); + assert.deepStrictEqual(s2.ticks(10), [0, 2, 15, 60, 300, 1000, 4000, 15000, 60000, 300000, 1000000]); +}); + it("symlog.tickFormat() defaults to SI prefix format, computed per tick", () => { const s = scaleSymlog().domain([0, 1e6]); const f = s.tickFormat(); From 594a82dd28b3922866ff8b7d8d145176b304be5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 23 Feb 2026 22:44:03 +0100 Subject: [PATCH 3/3] we need the 1/s trick to avoid floating-point issues, see Plot's numberInterval for example --- src/symlog.js | 11 ++++++++++- test/symlog-test.js | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/symlog.js b/src/symlog.js index b284af5..855e4d6 100644 --- a/src/symlog.js +++ b/src/symlog.js @@ -69,7 +69,16 @@ function niceTicks(scale, start, stop, n, span) { const v = scale.invert(pi); const step = Math.abs(scale.invert(pi + h) - scale.invert(pi - h)); const s = tickStep(0, step / 2, 2); - ticks.add(s ? Math.round(v / s) * s : v); + if (s) { + if (s < 1 && Number.isInteger(1 / s)) { + const n = 1 / s; + ticks.add(Math.round(v * n) / n); + } else { + ticks.add(Math.round(v / s) * s); + } + } else { + ticks.add(v); + } } ticks.delete(0); return [...ticks]; diff --git a/test/symlog-test.js b/test/symlog-test.js index e42169b..e9f19dc 100644 --- a/test/symlog-test.js +++ b/test/symlog-test.js @@ -221,6 +221,11 @@ it("symlog.ticks() allocates ticks proportionally for asymmetric domains", () => it("symlog.ticks() works when zero is not in the domain", () => { assert.deepStrictEqual(scaleSymlog().domain([100, 1e6]).ticks(10), [100, 250, 600, 1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000]); assert.deepStrictEqual(scaleSymlog().domain([1e6, 100]).ticks(10), [1000000, 400000, 150000, 60000, 25000, 10000, 4000, 1500, 600, 250, 100]); + assert.deepStrictEqual(scaleSymlog().domain([10, 20]).ticks(10), [10, 10.8, 11.6, 12.4, 13.2, 14.2, 15.2, 16.2, 17.4, 18.5, 20]); +}); + +it("symlog.ticks() works for a small positive domain", () => { + assert.deepStrictEqual(scaleSymlog().domain([0, 80]).ticks(10), [0, 0.6, 1.4, 2.5, 5, 8, 12, 20, 35, 50, 80]); }); it("symlog.ticks() handles edge cases", () => {