From e8e41496049a02df1f7472e8141369afe356e343 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 24 Jan 2022 16:06:16 -0800 Subject: [PATCH] labelArrow --- src/axes.js | 83 +++++++++++++++++++--------------- src/axis.js | 10 ++++ test/plots/aapl-candlestick.js | 3 +- test/scales/scales-test.js | 26 +++++------ 4 files changed, 71 insertions(+), 51 deletions(-) diff --git a/src/axes.js b/src/axes.js index 70343a8047..13e72ebfdd 100644 --- a/src/axes.js +++ b/src/axes.js @@ -71,7 +71,7 @@ export function autoScaleLabels(channels, scales, {x, y, fx, fy}, dimensions, op } for (const [key, type] of registry) { if (type !== position && scales[key]) { // not already handled above - autoScaleLabel(key, scales[key], channels.get(key), options[key]); + autoScaleLabel(scales[key], channels.get(key), options[key]); } } } @@ -83,10 +83,8 @@ function autoAxisLabelsX(axis, scale, channels) { : scaleOrder(scale) < 0 ? "left" : "right"; } - if (axis.label === undefined) { - axis.label = inferLabel(channels, scale, axis, "x"); - } - scale.label = axis.label; + autoScaleLabel(scale, channels, axis); + autoAxisLabel("x", scale, axis); } // Mutates axis.labelAnchor, axis.label, scale.label! @@ -96,19 +94,42 @@ function autoAxisLabelsY(axis, opposite, scale, channels) { : opposite && opposite.axis === "top" ? "bottom" // TODO scaleOrder? : "top"; } - if (axis.label === undefined) { - axis.label = inferLabel(channels, scale, axis, "y"); - } - scale.label = axis.label; + autoScaleLabel(scale, channels, axis); + autoAxisLabel("y", scale, axis); } -// Mutates scale.label! -function autoScaleLabel(key, scale, channels, options) { - if (options) { - scale.label = options.label; - } +// Mutates scale.label, axis.labelArrow! +function autoScaleLabel(scale, channels, axis) { + if (axis) scale.label = axis.label; // propagate manual label, if any, to scale if (scale.label === undefined) { - scale.label = inferLabel(channels, scale, null, key); + scale.label = inferLabel(channels, scale); + if (axis) axis.label = scale.label; // propagate automatic label, if used, to axis + } else if (axis?.labelArrow === "auto") { + axis.labelArrow = "none"; // clear default label arrow if explicit label + } +} + +// Mutates axis.label! +function autoAxisLabel(key, scale, axis) { + let {label, labelAnchor, labelArrow} = axis; + if (label != null) { + if (labelArrow === "auto" && !isOrdinalScale(scale)) { + const order = scaleOrder(scale); + if (order) { + if (key === "x" || labelAnchor === "center") { + labelArrow = key === "x" === order < 0 ? "left" : "right"; + } else { + labelArrow = order < 0 ? "up" : "down"; + } + } + } + switch (labelArrow) { + case "left": label = `← ${label}`; break; + case "right": label = `${label} →`; break; + case "up": label = `↑ ${label}`; break; + case "down": label = `↓ ${label}`; break; + } + axis.label = label; } } @@ -116,31 +137,19 @@ function autoScaleLabel(key, scale, channels, options) { // consistently labeled (i.e., have the same value if not undefined), and the // corresponding axis doesn’t already have an explicit label, then the channels’ // label is promoted to the corresponding axis. -function inferLabel(channels = [], scale, axis, key) { - let candidate; - for (const {label} of channels) { - if (label === undefined) continue; - if (candidate === undefined) candidate = label; - else if (candidate !== label) return; +function inferLabel(channels = [], scale) { + let label; + for (const {label: channelLabel} of channels) { + if (channelLabel === undefined) continue; + if (label === undefined) label = channelLabel; + else if (label !== channelLabel) return; } - if (candidate !== undefined) { + if (label !== undefined) { // Ignore the implicit label for temporal scales if it’s simply “date”. - if (isTemporalScale(scale) && /^(date|time|year)$/i.test(candidate)) return; - if (!isOrdinalScale(scale)) { - if (scale.percent) candidate = `${candidate} (%)`; - if (key === "x" || key === "y") { - const order = scaleOrder(scale); - if (order) { - if (key === "x" || (axis && axis.labelAnchor === "center")) { - candidate = key === "x" === order < 0 ? `← ${candidate}` : `${candidate} →`; - } else { - candidate = `${order < 0 ? "↑ " : "↓ "}${candidate}`; - } - } - } - } + if (isTemporalScale(scale) && /^(date|time|year)$/i.test(label)) return; + if (!isOrdinalScale(scale) && scale.percent) label = `${label} (%)`; } - return candidate; + return label; } export function inferFontVariant(scale) { diff --git a/src/axis.js b/src/axis.js index 8a00001b51..54c2ce904d 100644 --- a/src/axis.js +++ b/src/axis.js @@ -4,6 +4,12 @@ import {formatIsoDate} from "./format.js"; import {radians} from "./math.js"; import {impliedString} from "./style.js"; +function maybeLabelArrow(labelArrow = true) { + return labelArrow === true ? "auto" + : labelArrow === false || labelArrow == null ? "none" + : keyword(labelArrow, "labelArrow", ["auto", "none", "up", "down", "left", "right"]); +} + export class AxisX { constructor({ name = "x", @@ -16,6 +22,7 @@ export class AxisX { grid, label, labelAnchor, + labelArrow, labelOffset, line, tickRotate @@ -30,6 +37,7 @@ export class AxisX { this.grid = boolean(grid); this.label = string(label); this.labelAnchor = maybeKeyword(labelAnchor, "labelAnchor", ["center", "left", "right"]); + this.labelArrow = maybeLabelArrow(labelArrow); this.labelOffset = number(labelOffset); this.line = boolean(line); this.tickRotate = number(tickRotate); @@ -103,6 +111,7 @@ export class AxisY { grid, label, labelAnchor, + labelArrow, labelOffset, line, tickRotate @@ -117,6 +126,7 @@ export class AxisY { this.grid = boolean(grid); this.label = string(label); this.labelAnchor = maybeKeyword(labelAnchor, "labelAnchor", ["center", "top", "bottom"]); + this.labelArrow = maybeLabelArrow(labelArrow); this.labelOffset = number(labelOffset); this.line = boolean(line); this.tickRotate = number(tickRotate); diff --git a/test/plots/aapl-candlestick.js b/test/plots/aapl-candlestick.js index 43054b81e2..3d3211c7f3 100644 --- a/test/plots/aapl-candlestick.js +++ b/test/plots/aapl-candlestick.js @@ -7,7 +7,8 @@ export default async function() { inset: 6, grid: true, y: { - label: "↑ Apple stock price ($)", + label: "Apple stock price ($)", + labelArrow: "up", fontVariant: null }, color: { diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 77ecfc3b49..dc5235b904 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -44,7 +44,7 @@ it("plot(…).scale('x') returns the expected linear scale for penguins", async range: [20, 620], interpolate: d3.interpolateNumber, clamp: false, - label: "body_mass_g →" + label: "body_mass_g" }); }); @@ -307,7 +307,7 @@ it("plot(…).scale(name) promotes the given zero option to the domain", async ( range: [20, 620], interpolate: d3.interpolateNumber, clamp: false, - label: "body_mass_g →" + label: "body_mass_g" }); }); @@ -320,7 +320,7 @@ it("plot(…).scale(name) handles the zero option correctly for descending domai range: [20, 620], interpolate: d3.interpolateNumber, clamp: false, - label: "← body_mass_g" + label: "body_mass_g" }); }); @@ -333,7 +333,7 @@ it("plot(…).scale(name) handles the zero option correctly for polylinear domai range: [20, 320, 620], interpolate: d3.interpolateNumber, clamp: false, - label: "body_mass_g →" + label: "body_mass_g" }); }); @@ -346,7 +346,7 @@ it("plot(…).scale(name) handles the zero option correctly for descending polyl range: [20, 320, 620], interpolate: d3.interpolateNumber, clamp: false, - label: "← body_mass_g" + label: "body_mass_g" }); }); @@ -1174,13 +1174,13 @@ it("plot({padding, …}).scale('x').padding reflects the given padding option fo }); it("plot(…).scale('x').label reflects the default label for named fields, possibly reversed", () => { - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: "foo"}).plot().scale("x").label, "foo →"); - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: "foo"}).plot({x: {reverse: true}}).scale("x").label, "← foo"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: "foo"}).plot().scale("x").label, "foo"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: "foo"}).plot({x: {reverse: true}}).scale("x").label, "foo"); }); it("plot(…).scale('y').label reflects the default label for named fields, possibly reversed", () => { - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {y: "foo"}).plot().scale("y").label, "↑ foo"); - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {y: "foo"}).plot({y: {reverse: true}}).scale("y").label, "↓ foo"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {y: "foo"}).plot().scale("y").label, "foo"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {y: "foo"}).plot({y: {reverse: true}}).scale("y").label, "foo"); }); it("plot(…).scale('x').label reflects the explicit label", () => { @@ -1190,13 +1190,13 @@ it("plot(…).scale('x').label reflects the explicit label", () => { it("plot(…).scale('x').label reflects a function label, if not overridden by an explicit label", () => { const foo = Object.assign(d => d.foo, {label: "Foo"}); - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot().scale("x").label, "Foo →"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot().scale("x").label, "Foo"); assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot({x: {label: null}}).scale("x").label, null); }); it("plot(…).scale('x').label reflects a channel transform label, if not overridden by an explicit label", () => { const foo = {transform: data => data.map(d => d.foo), label: "Foo"}; - assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot().scale("x").label, "Foo →"); + assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot().scale("x").label, "Foo"); assert.strictEqual(Plot.dot([{foo: 1}, {foo: 2}, {foo: 3}], {x: foo}).plot({x: {label: null}}).scale("x").label, null); }); @@ -1305,7 +1305,7 @@ it("plot(…).scale(name) reflects the given custom interpolator", async () => { range: [20, 620], interpolate, clamp: false, - label: "body_mass_g →" + label: "body_mass_g" }); }); @@ -1362,7 +1362,7 @@ it("plot(…).scale(name) reflects the given transform", async () => { clamp: false, interpolate: d3.interpolateNumber, transform, - label: "body_mass_g →" + label: "body_mass_g" }); });