Skip to content

Commit

Permalink
✨ (stacked bar) align x-axis of facets / TAS-548 (#3726)
Browse files Browse the repository at this point in the history
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  |
| ------- | ------ |
| <img width="965" alt="Screenshot 2024-06-21 at 17 30 05" src="https://github.com/owid/owid-grapher/assets/12461810/1c231224-f4c2-483d-9e73-24054cafe4c6">  | <img width="965" alt="Screenshot 2024-06-21 at 17 30 40" src="https://github.com/owid/owid-grapher/assets/12461810/560274d0-c784-475f-b8c3-37b565e184a8"> |

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
  • Loading branch information
sophiamersmann authored Jul 2, 2024
1 parent 6cafe62 commit 0800199
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 184 deletions.
68 changes: 61 additions & 7 deletions packages/@ourworldindata/grapher/src/axis/Axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ interface TickLabelPlacement {
isHidden: boolean
}

type Scale = ScaleLinear<number, number> | ScaleLogarithmic<number, number>

const OUTER_PADDING = 4

const doIntersect = (bounds: Bounds, bounds2: Bounds): boolean => {
return bounds.intersects(bounds2)
}
Expand Down Expand Up @@ -166,13 +170,62 @@ abstract class AbstractAxis {
return this
}

@computed private get d3_scale():
| ScaleLinear<number, number>
| ScaleLogarithmic<number, number> {
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 {
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions packages/@ourworldindata/grapher/src/axis/AxisConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,7 @@ export class AxisConfig
facetDomain: this.facetDomain,
ticks: this.ticks,
singleValueAxisPointAlign: this.singleValueAxisPointAlign,
domainValues: this.domainValues,
})

deleteRuntimeAndUnchangedProps(obj, new AxisConfigDefaults())
Expand Down
24 changes: 19 additions & 5 deletions packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PositionMap,
HorizontalAlign,
Color,
uniq,
} from "@ourworldindata/utils"
import { shortenForTargetWidth } from "@ourworldindata/components"
import { action, computed, observable } from "mobx"
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGGElement> {
dualAxis: DualAxis
Expand Down Expand Up @@ -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) => {
Expand Down
Loading

0 comments on commit 0800199

Please sign in to comment.