diff --git a/src/symlog.js b/src/symlog.js index 125fa7b..855e4d6 100644 --- a/src/symlog.js +++ b/src/symlog.js @@ -1,3 +1,5 @@ +import {ascending, descending, tickStep} from "d3-array"; +import {format, formatSpecifier} from "d3-format"; import {linearish} from "./linear.js"; import {copy, transformer} from "./continuous.js"; import {initRange} from "./init.js"; @@ -21,7 +23,65 @@ export function symlogish(transform) { return arguments.length ? transform(transformSymlog(c = +_), transformSymexp(c)) : c; }; - return linearish(scale); + linearish(scale); + + 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); + if (specifier.precision == null) specifier.trim = true; + specifier = format(specifier); + } + return specifier; + }; + + 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); + 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]; } export default function symlog() { diff --git a/test/symlog-test.js b/test/symlog-test.js index 7b65749..e9f19dc 100644 --- a/test/symlog-test.js +++ b/test/symlog-test.js @@ -168,3 +168,97 @@ 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.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]); + 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", () => { + 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(); + 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"); +});