From 08001998de73964c2f111e6d04f79cc7262e1321 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Tue, 2 Jul 2024 14:48:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(stacked=20bar)=20align=20x-axis=20?= =?UTF-8?q?of=20facets=20/=20TAS-548=20(#3726)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns x-axes of faceted StackedBar charts, resolves #2160 Implemented as part of an effort to create a set of climate charts, see draft charts on [this staging site](http://staging-site-stacked-bar/admin/charts) ## Summary - Replaces the StackedBar chart's custom x-axis implementation with `DualAxisComponent` - This works by passing a list of `domainValues` to the axis – it's then the axis' job (i) to figure out how much space is available for a single domain value (called the `bandWidth`) and (ii) to adjust the scale in such a way that we have sufficient padding on both sides for the outer bars to fit - We also pass custom x-ticks to the axis, based on the actual x-values in the chart - The `domainValues` and the `ticks` are shared across all facets, so that we end up with the same x-axis for all facets ## Screenshots | Before | After | | ------- | ------ | | Screenshot 2024-06-21 at 17 30 05 | Screenshot 2024-06-21 at 17 30 40 | Link: http://staging-site-stacked-bar-dual-axis/grapher/natural-disasters-by-type?facet=entity&uniformYAxis=0&country=Wildfire~Glacial+lake+outburst+flood~Dry+mass+movement~Extreme+temperature --- .../@ourworldindata/grapher/src/axis/Axis.ts | 68 +++++- .../grapher/src/axis/AxisConfig.ts | 2 + .../grapher/src/facetChart/FacetChart.tsx | 24 ++- .../stackedCharts/AbstractStackedChart.tsx | 37 ++-- .../src/stackedCharts/StackedAreaChart.tsx | 11 + .../src/stackedCharts/StackedBarChart.tsx | 200 +++++------------- .../types/src/grapherTypes/GrapherTypes.ts | 7 + 7 files changed, 165 insertions(+), 184 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.ts b/packages/@ourworldindata/grapher/src/axis/Axis.ts index 61c3f260d0a..5f1870a1a46 100644 --- a/packages/@ourworldindata/grapher/src/axis/Axis.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.ts @@ -39,6 +39,10 @@ interface TickLabelPlacement { isHidden: boolean } +type Scale = ScaleLinear | ScaleLogarithmic + +const OUTER_PADDING = 4 + const doIntersect = (bounds: Bounds, bounds2: Bounds): boolean => { return bounds.intersects(bounds2) } @@ -166,13 +170,62 @@ abstract class AbstractAxis { return this } - @computed private get d3_scale(): - | ScaleLinear - | ScaleLogarithmic { + private static calculateBandWidth({ + values, + scale, + }: { + values: number[] + scale: Scale + }): number { + const range = scale.range() + const rangeSize = Math.abs(range[1] - range[0]) + const maxBandWidth = 0.4 * rangeSize + + if (values.length < 2) return maxBandWidth + + // the band width is the smallest distance between + // two adjacent values placed on the axis + const sortedValues = sortBy(values) + const positions = sortedValues.map((value) => scale(value)) + const diffs = positions + .slice(1) + .map((pos, index) => pos - positions[index]) + const bandWidth = min(diffs) ?? 0 + + return min([bandWidth, maxBandWidth]) ?? 0 + } + + /** + * Maximum width a single value can take up on the axis. + * Not meaningful if no domain values are given. + */ + @computed get bandWidth(): number | undefined { + const { domainValues } = this.config + if (!domainValues) return undefined + return AbstractAxis.calculateBandWidth({ + values: domainValues, + scale: this.d3_scale, + }) + } + + @computed private get d3_scale(): Scale { const d3Scale = this.scaleType === ScaleType.log ? scaleLog : scaleLinear - const scale = d3Scale().domain(this.domain).range(this.range) - return this.nice ? scale.nice(this.totalTicksTarget) : scale + let scale = d3Scale().domain(this.domain).range(this.range) + scale = this.nice ? scale.nice(this.totalTicksTarget) : scale + + if (this.config.domainValues) { + // compute bandwidth and adjust the scale + const bandWidth = AbstractAxis.calculateBandWidth({ + values: this.config.domainValues, + scale, + }) + const offset = bandWidth / 2 + OUTER_PADDING + const r = scale.range() + return scale.range([r[0] + offset, r[1] - offset]) + } else { + return scale + } } @computed get rangeSize(): number { @@ -536,11 +589,12 @@ export class HorizontalAxis extends AbstractAxis { let xAlign = HorizontalAlign.center const left = x - width / 2 const right = x + width / 2 - if (left < this.rangeMin) { + const offset = this.bandWidth ? this.bandWidth / 2 + OUTER_PADDING : 0 + if (left < this.rangeMin - offset) { x = this.rangeMin xAlign = HorizontalAlign.left } - if (right > this.rangeMax) { + if (right > this.rangeMax + offset) { x = this.rangeMax xAlign = HorizontalAlign.right } diff --git a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts index 296c4db0457..cdc8d6486cd 100644 --- a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts +++ b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts @@ -40,6 +40,7 @@ class AxisConfigDefaults implements AxisConfigInterface { @observable.ref ticks?: Tickmark[] = undefined @observable.ref singleValueAxisPointAlign?: AxisAlign = undefined @observable.ref label: string = "" + @observable.ref domainValues?: number[] = undefined } export class AxisConfig @@ -78,6 +79,7 @@ export class AxisConfig facetDomain: this.facetDomain, ticks: this.ticks, singleValueAxisPointAlign: this.singleValueAxisPointAlign, + domainValues: this.domainValues, }) deleteRuntimeAndUnchangedProps(obj, new AxisConfigDefaults()) diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 91af5f982f4..35ce446c21e 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -18,6 +18,7 @@ import { PositionMap, HorizontalAlign, Color, + uniq, } from "@ourworldindata/utils" import { shortenForTargetWidth } from "@ourworldindata/components" import { action, computed, observable } from "mobx" @@ -393,14 +394,27 @@ export class FacetChart (axis) => axis?.size ) if (uniform) { - // If the axes are uniform, we want to find the full domain extent across all facets - const domains = excludeUndefined( - intermediateChartInstances - .map(axisAccessor) - .map((axis) => axis?.domain) + const axes = excludeUndefined( + intermediateChartInstances.map(axisAccessor) ) + + // If the axes are uniform, we want to find the full domain extent across all facets + const domains = axes.map((axis) => axis.domain) config.min = min(domains.map((d) => d[0])) config.max = max(domains.map((d) => d[1])) + + // Find domain values across all facets + const domainValues = uniq( + axes.flatMap((axis) => axis.config.domainValues ?? []) + ) + if (domainValues.length > 0) config.domainValues = domainValues + + // Find ticks across all facets + const ticks = uniq( + axes.flatMap((axis) => axis.config.ticks ?? []) + ) + if (ticks.length > 0) config.ticks = ticks + // If there was at least one chart with a non-undefined axis, // this variable will be populated if (axisWithMaxSize) { diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index 8028bfd98dc..846064dd28f 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -205,22 +205,22 @@ export class AbstractStackedChart return autoDetectSeriesStrategy(this.manager) } - @computed protected get dualAxis(): DualAxis { - const { - bounds, - horizontalAxisPart, - verticalAxisPart, - paddingForLegendRight, - paddingForLegendTop, - } = this - return new DualAxis({ - bounds: bounds - .padTop(paddingForLegendTop) - .padRight(paddingForLegendRight) + @computed get innerBounds(): Bounds { + return ( + this.bounds + .padTop(this.paddingForLegendTop) + .padRight(this.paddingForLegendRight) // top padding leaves room for tick labels .padTop(6) // bottom padding avoids axis labels to be cut off at some resolutions - .padBottom(2), + .padBottom(2) + ) + } + + @computed protected get dualAxis(): DualAxis { + const { horizontalAxisPart, verticalAxisPart } = this + return new DualAxis({ + bounds: this.innerBounds, horizontalAxis: horizontalAxisPart, verticalAxis: verticalAxisPart, }) @@ -234,14 +234,9 @@ export class AbstractStackedChart return this.dualAxis.horizontalAxis } - @computed private get xAxisConfig(): AxisConfig { - return new AxisConfig( - { - hideGridlines: true, - ...this.manager.xAxisConfig, - }, - this - ) + // implemented in subclasses + @computed protected get xAxisConfig(): AxisConfig { + return new AxisConfig() } @computed private get horizontalAxisPart(): HorizontalAxis { diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 83e4a593947..3eb316a7baa 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -54,6 +54,7 @@ import { import { stackSeries, withMissingValuesAsZeroes } from "./StackedUtils" import { makeClipPath } from "../chart/ChartUtils" import { bind } from "decko" +import { AxisConfig } from "../axis/AxisConfig.js" interface AreasProps extends React.SVGAttributes { dualAxis: DualAxis @@ -279,6 +280,16 @@ export class StackedAreaChart return [0, max(yValues) ?? 0] } + @computed protected get xAxisConfig(): AxisConfig { + return new AxisConfig( + { + hideGridlines: true, + ...this.manager.xAxisConfig, + }, + this + ) + } + @computed get midpoints(): number[] { let prevY = 0 return this.series.map((series) => { diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index bb4fd6b90bf..497b1d5624f 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -9,18 +9,12 @@ import { sum, getRelativeMouse, colorScaleConfigDefaults, - dyFromAlign, - makeIdForHumanConsumption, excludeUndefined, min, max, partition, } from "@ourworldindata/utils" -import { - VerticalAxisComponent, - HorizontalAxisTickMark, - VerticalAxisGridLines, -} from "../axis/AxisViews" +import { DualAxisComponent } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" import { VerticalColorLegend, @@ -37,7 +31,6 @@ import { import { BASE_FONT_SIZE, GRAPHER_AREA_OPACITY_DEFAULT, - GRAPHER_DARK_TEXT, GRAPHER_AXIS_LINE_WIDTH_DEFAULT, GRAPHER_AXIS_LINE_WIDTH_THICK, GRAPHER_FONT_SCALE_12, @@ -49,11 +42,7 @@ import { } from "./AbstractStackedChart" import { StackedPoint, StackedSeries } from "./StackedConstants" import { VerticalAxis } from "../axis/Axis" -import { - ColorSchemeName, - HorizontalAlign, - VerticalAlign, -} from "@ourworldindata/types" +import { ColorSchemeName, HorizontalAlign } from "@ourworldindata/types" import { stackSeriesInBothDirections, withMissingValuesAsZeroes, @@ -63,6 +52,7 @@ import { ColorScaleConfigDefaults } from "../color/ColorScaleConfig" import { ColumnTypeMap } from "@ourworldindata/core-table" import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" +import { AxisConfig } from "../axis/AxisConfig.js" interface StackedBarSegmentProps extends React.SVGAttributes { bar: StackedPoint