Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/symlog.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() {
Expand Down
94 changes: 94 additions & 0 deletions test/symlog-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});