From 65ed9a45f4027166835c546dfff85d591ca8a401 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 10 Jan 2025 16:44:06 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20use=20'nice'=20axis=20ticks=20for?= =?UTF-8?q?=20linear=20scales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚧 remember nice ticks fine-tune --- .../grapher/src/axis/Axis.test.ts | 18 ++++++- .../@ourworldindata/grapher/src/axis/Axis.ts | 49 +++++++++++++++++-- .../grapher/src/lineCharts/LineChart.tsx | 2 +- .../stackedCharts/AbstractStackedChart.tsx | 9 +++- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.test.ts b/packages/@ourworldindata/grapher/src/axis/Axis.test.ts index a24e55fbf56..2d135aa3354 100755 --- a/packages/@ourworldindata/grapher/src/axis/Axis.test.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.test.ts @@ -7,7 +7,7 @@ import { SynthesizeGDPTable, } from "@ourworldindata/core-table" import { AxisConfig } from "./AxisConfig" -import { AxisAlign } from "@ourworldindata/utils" +import { AxisAlign, last } from "@ourworldindata/utils" it("can create an axis", () => { const axisConfig = new AxisConfig({ @@ -88,7 +88,21 @@ it("respects nice parameter", () => { axis.range = [0, 300] const tickValues = axis.getTickValues() expect(tickValues[0].value).toEqual(0) - expect(tickValues[tickValues.length - 1].value).toEqual(100) + expect(last(tickValues)?.value).toEqual(100) +}) + +it("fine-tunes d3's nice implementation", () => { + const config: AxisConfigInterface = { + min: 0.0001, + max: 90.0001, + maxTicks: 10, + nice: true, + } + const axis = new AxisConfig(config).toVerticalAxis() + axis.range = [0, 300] + const tickValues = axis.getTickValues() + expect(tickValues[0].value).toEqual(0) + expect(last(tickValues)?.value).toEqual(90) }) it("creates compact labels", () => { diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.ts b/packages/@ourworldindata/grapher/src/axis/Axis.ts index eb9f2e9159f..f20b2b6a3cb 100644 --- a/packages/@ourworldindata/grapher/src/axis/Axis.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.ts @@ -21,6 +21,7 @@ import { ValueRange, cloneDeep, OwidVariableRoundingMode, + last, } from "@ourworldindata/utils" import { AxisConfig, AxisManager } from "./AxisConfig" import { MarkdownTextWrap } from "@ourworldindata/components" @@ -209,11 +210,49 @@ abstract class AbstractAxis { }) } + private static makeScaleNice( + scale: ScaleLinear, + totalTicksTarget: number + ): { scale: ScaleLinear; ticks?: number[] } { + let ticks = scale.ticks(totalTicksTarget) + + // use d3's nice function when there is only one tick + if (ticks.length < 2) return { scale: scale.nice(totalTicksTarget) } + + const tickStep = ticks[1] - ticks[0] + const firstTick = ticks[0] + const lastTick = last(ticks)! + + // if the the max or min value exceeds the last grid line by more than 25%, + // expand the domain to include an additional grid line + const [minValue, maxValue] = scale.domain() + if (maxValue > lastTick + 0.25 * tickStep) { + scale.domain([scale.domain()[0], lastTick + tickStep]) + ticks = [...ticks, lastTick + tickStep] + } + if (minValue < firstTick - 0.25 * tickStep) { + scale.domain([firstTick - tickStep, scale.domain()[1]]) + ticks = [firstTick - tickStep, ...ticks] + } + + return { scale, ticks } + } + + @observable private niceTicks?: number[] @computed private get d3_scale(): Scale { - const d3Scale = - this.scaleType === ScaleType.log ? scaleLog : scaleLinear + const isLogScale = this.scaleType === ScaleType.log + const d3Scale = isLogScale ? scaleLog : scaleLinear let scale = d3Scale().domain(this.domain).range(this.range) - scale = this.nice ? scale.nice(this.totalTicksTarget) : scale + if (this.nice && !isLogScale) { + const niceResult = AbstractAxis.makeScaleNice( + scale, + this.totalTicksTarget + ) + scale = niceResult.scale + this.niceTicks = niceResult.ticks + } else { + this.niceTicks = undefined + } if (this.config.domainValues) { // compute bandwidth and adjust the scale @@ -356,7 +395,9 @@ abstract class AbstractAxis { } else { // Only use priority 2 here because we want the start / end ticks // to be priority 1 - ticks = d3_scale.ticks(this.totalTicksTarget).map((tickValue) => ({ + const d3_ticks = + this.niceTicks ?? d3_scale.ticks(this.totalTicksTarget) + ticks = d3_ticks.map((tickValue) => ({ value: tickValue, priority: 2, })) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 3369bbbfa26..6b842582125 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -1418,9 +1418,9 @@ export class LineChart } @computed private get yAxisConfig(): AxisConfig { - // TODO: enable nice axis ticks for linear scales return new AxisConfig( { + nice: true, // if we only have a single y value (probably 0), we want the // horizontal axis to be at the bottom of the chart. // see https://github.com/owid/owid-grapher/pull/975#issuecomment-890798547 diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index 5b40a3086bb..812da2c3814 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -253,8 +253,13 @@ export class AbstractStackedChart } @computed private get yAxisConfig(): AxisConfig { - // TODO: enable nice axis ticks for linear scales - return new AxisConfig(this.manager.yAxisConfig, this) + return new AxisConfig( + { + nice: true, + ...this.manager.yAxisConfig, + }, + this + ) } // implemented in subclasses