diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.ts b/packages/@ourworldindata/grapher/src/axis/Axis.ts index eb9f2e9159f..62e4251fcd3 100644 --- a/packages/@ourworldindata/grapher/src/axis/Axis.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.ts @@ -42,6 +42,7 @@ interface TickLabelPlacement { type Scale = ScaleLinear | ScaleLogarithmic const OUTER_PADDING = 4 +const MAX_LABEL_WIDTH = 320 const doIntersect = (bounds: Bounds, bounds2: Bounds): boolean => { return bounds.intersects(bounds2) @@ -89,6 +90,8 @@ abstract class AbstractAxis { abstract get size(): number abstract get orient(): Position abstract get labelWidth(): number + abstract get labelPadding(): number + abstract get tickPadding(): number abstract placeTickLabel(value: number): TickLabelPlacement abstract get tickLabels(): TickLabelPlacement[] @@ -101,8 +104,8 @@ abstract class AbstractAxis { return this.config.hideGridlines ?? false } - @computed get labelPadding(): number { - return this.config.labelPadding ?? 5 + @computed get labelPosition(): AxisAlign { + return this.config.labelPosition ?? AxisAlign.middle } @computed get nice(): boolean { @@ -511,14 +514,22 @@ export class HorizontalAxis extends AbstractAxis { : Position.bottom } + @computed get labelPadding(): number { + return this.config.labelPadding ?? 10 + } + + @computed get tickPadding(): number { + return this.config.tickPadding ?? 5 + } + @computed get labelOffset(): number { return this.labelTextWrap - ? this.labelTextWrap.height + this.labelPadding * 2 + ? this.labelTextWrap.height + this.labelPadding : 0 } @computed get labelWidth(): number { - return this.rangeSize + return Math.min(MAX_LABEL_WIDTH, this.rangeSize) } // note that we intentionally don't take `hideAxisLabels` into account here. @@ -527,12 +538,10 @@ export class HorizontalAxis extends AbstractAxis { // we might end up with misaligned axes. @computed get height(): number { if (this.hideAxis) return 0 - const { labelOffset, labelPadding } = this + const { labelOffset, tickPadding } = this const maxTickHeight = max(this.tickLabels.map((tick) => tick.height)) - const height = maxTickHeight - ? maxTickHeight + labelOffset + labelPadding - : 0 - return Math.max(height, this.config.minSize ?? 0) + const tickHeight = maxTickHeight ? maxTickHeight + tickPadding : 0 + return Math.max(tickHeight + labelOffset, this.config.minSize ?? 0) } @computed get size(): number { @@ -630,13 +639,23 @@ export class VerticalAxis extends AbstractAxis { return Position.left } + @computed get labelPadding(): number { + return this.config.labelPadding ?? 16 + } + + @computed get tickPadding(): number { + return this.config.tickPadding ?? 5 + } + @computed get labelWidth(): number { - return this.height + if (this.labelPosition === AxisAlign.middle) return this.height + const availableWidth = this.axisManager?.axisBounds?.width ?? Infinity + return Math.min(availableWidth, MAX_LABEL_WIDTH) } @computed get labelOffset(): number { return this.labelTextWrap - ? this.labelTextWrap.height + this.labelPadding * 2 + ? this.labelTextWrap.height + this.labelPadding : 0 } @@ -646,13 +665,13 @@ export class VerticalAxis extends AbstractAxis { // we might end up with misaligned axes. @computed get width(): number { if (this.hideAxis) return 0 - const { labelOffset, labelPadding } = this + const { tickPadding, labelPosition } = this const maxTickWidth = max(this.tickLabels.map((tick) => tick.width)) - const width = - maxTickWidth !== undefined - ? maxTickWidth + labelOffset + labelPadding - : 0 - return Math.max(width, this.config.minSize ?? 0) + const tickWidth = + maxTickWidth !== undefined ? maxTickWidth + tickPadding : 0 + const labelOffset = + labelPosition === AxisAlign.middle ? this.labelOffset : 0 + return Math.max(tickWidth + labelOffset, this.config.minSize ?? 0) } @computed get height(): number { @@ -766,12 +785,23 @@ export class DualAxis { return axis.size } + @computed private get verticalAxisLabelOffset(): number { + return this.props.verticalAxis.labelPosition === AxisAlign.middle + ? 0 + : this.props.verticalAxis.labelOffset + } + // Now we can determine the "true" inner bounds of the dual axis @computed get innerBounds(): Bounds { - return this.bounds.pad({ - [this.props.horizontalAxis.orient]: this.horizontalAxisSize, - [this.props.verticalAxis.orient]: this.verticalAxisSize, - }) + return ( + this.bounds + .pad({ + [this.props.horizontalAxis.orient]: this.horizontalAxisSize, + [this.props.verticalAxis.orient]: this.verticalAxisSize, + }) + // make space for the x-axis label if plotted above the axis + .padTop(this.verticalAxisLabelOffset) + ) } @computed get bounds(): Bounds { diff --git a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts index 676da96add0..326e591d163 100644 --- a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts +++ b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts @@ -7,6 +7,7 @@ import { AxisAlign, Position, TickFormattingOptions, + Bounds, } from "@ourworldindata/utils" import { observable, computed } from "mobx" import { HorizontalAxis, VerticalAxis } from "./Axis" @@ -20,6 +21,7 @@ import { export interface AxisManager { fontSize: number + axisBounds?: Bounds detailsOrderedByReference?: string[] } @@ -33,7 +35,9 @@ class AxisConfigDefaults implements AxisConfigInterface { @observable.ref hideAxis?: boolean = undefined @observable.ref hideGridlines?: boolean = undefined @observable.ref hideTickLabels?: boolean = undefined + @observable.ref labelPosition?: AxisAlign.middle | AxisAlign.end = undefined @observable.ref labelPadding?: number = undefined + @observable.ref tickPadding?: number = undefined @observable.ref nice?: boolean = undefined @observable.ref maxTicks?: number = undefined @observable.ref tickFormattingOptions?: TickFormattingOptions = undefined diff --git a/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx b/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx index bd4c533dc97..fcd004eaa5c 100644 --- a/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx +++ b/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx @@ -14,7 +14,7 @@ import { import { VerticalAxis, HorizontalAxis, DualAxis } from "./Axis" import classNames from "classnames" import { GRAPHER_DARK_TEXT } from "../color/ColorConstants" -import { ScaleType, DetailsMarker } from "@ourworldindata/types" +import { ScaleType, DetailsMarker, AxisAlign } from "@ourworldindata/types" const dasharrayFromFontSize = (fontSize: number): string => { const dashLength = Math.round((fontSize / 16) * 3) @@ -265,27 +265,27 @@ export class VerticalAxisComponent extends React.Component<{ } = this.props const { tickLabels, labelTextWrap, config } = verticalAxis + const isLabelCentered = verticalAxis.labelPosition === AxisAlign.middle + const labelX = isLabelCentered ? -verticalAxis.rangeCenter : bounds.left + const labelY = isLabelCentered ? bounds.left : bounds.top + return ( {labelTextWrap && - labelTextWrap.renderSVG( - -verticalAxis.rangeCenter, - bounds.left, - { - id: makeIdForHumanConsumption( - "vertical-axis-label" - ), - textProps: { - transform: "rotate(-90)", - fill: labelColor || GRAPHER_DARK_TEXT, - textAnchor: "middle", - }, - detailsMarker, - } - )} + labelTextWrap.renderSVG(labelX, labelY, { + id: makeIdForHumanConsumption("vertical-axis-label"), + textProps: { + transform: isLabelCentered + ? "rotate(-90)" + : undefined, + fill: labelColor || GRAPHER_DARK_TEXT, + textAnchor: isLabelCentered ? "middle" : "start", + }, + detailsMarker, + })} {showTickMarks && ( {tickLabels.map((label, i) => ( @@ -315,7 +315,7 @@ export class VerticalAxisComponent extends React.Component<{ x={( bounds.left + verticalAxis.width - - verticalAxis.labelPadding + verticalAxis.tickPadding ).toFixed(2)} y={y} dy={dyFromAlign( @@ -392,17 +392,20 @@ export class HorizontalAxisComponent extends React.Component<{ const showTickLabels = !axis.config.hideTickLabels + const isLabelCentered = axis.labelPosition === AxisAlign.middle + const labelX = isLabelCentered ? axis.rangeCenter : bounds.right + return ( {label && - label.renderSVG(axis.rangeCenter, labelYPosition, { + label.renderSVG(labelX, labelYPosition, { id: makeIdForHumanConsumption("horizontal-axis-label"), textProps: { fill: labelColor || GRAPHER_DARK_TEXT, - textAnchor: "middle", + textAnchor: isLabelCentered ? "middle" : "end", }, detailsMarker, })} diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index bb7743b90aa..6993f08aed5 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -11,6 +11,7 @@ import { ColorSchemeName, ValueRange, ColumnSlug, + AxisAlign, } from "@ourworldindata/types" import { ComparisonLine } from "../scatterCharts/ComparisonLine" import { observable, computed, action } from "mobx" @@ -365,6 +366,10 @@ export class ScatterPlotChart ) } + @computed get axisBounds(): Bounds { + return this.innerBounds + } + @computed private get canAddCountry(): boolean { const { addCountryMode } = this.manager return (addCountryMode && @@ -558,7 +563,7 @@ export class ScatterPlotChart @computed get dualAxis(): DualAxis { const { horizontalAxisPart, verticalAxisPart } = this return new DualAxis({ - bounds: this.innerBounds, + bounds: this.axisBounds, horizontalAxis: horizontalAxisPart, verticalAxis: verticalAxisPart, }) @@ -1049,15 +1054,23 @@ export class ScatterPlotChart @computed private get yAxisConfig(): AxisConfig { const { yAxisConfig = {} } = this.manager - const labelPadding = this.manager.isNarrow ? 2 : undefined - const config = { ...yAxisConfig, labelPadding } + const labelPadding = this.manager.isNarrow ? 10 : undefined + const config = { + ...yAxisConfig, + labelPosition: AxisAlign.end, + labelPadding, + } return new AxisConfig(config, this) } @computed private get xAxisConfig(): AxisConfig { const { xAxisConfig = {} } = this.manager - const labelPadding = this.manager.isNarrow ? 2 : undefined - const config = { ...xAxisConfig, labelPadding } + const labelPadding = this.manager.isNarrow ? 8 : undefined + const config = { + ...xAxisConfig, + labelPosition: AxisAlign.end, + labelPadding, + } return new AxisConfig(config, this) } diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index c10f3308a6c..58935b8776f 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -263,12 +263,22 @@ export interface AxisConfigInterface { minSize?: number /** - * The padding between: - * - an axis tick and an axis gridline - * - an axis label and an axis tick + * Position of the axis label. 'start' is not supported. + * For vertical axes, 'middle' rotates the label and places it next to the axis, + * 'end' places the label above the axis. + */ + labelPosition?: AxisAlign + + /** + * The padding between an axis label and an axis tick */ labelPadding?: number + /** + * The padding between an axis tick and an axis gridline + */ + tickPadding?: number + /** * Extend scale to start & end on "nicer" round values. * See: https://github.com/d3/d3-scale#continuous_nice