diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.ts b/packages/@ourworldindata/grapher/src/axis/Axis.ts index eb9f2e9159f..25c7c1bdc0f 100644 --- a/packages/@ourworldindata/grapher/src/axis/Axis.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.ts @@ -88,7 +88,8 @@ abstract class AbstractAxis { */ abstract get size(): number abstract get orient(): Position - abstract get labelWidth(): number + abstract get labelMaxWidth(): number + abstract get labelPadding(): number abstract placeTickLabel(value: number): TickLabelPlacement abstract get tickLabels(): TickLabelPlacement[] @@ -101,8 +102,12 @@ abstract class AbstractAxis { return this.config.hideGridlines ?? false } - @computed get labelPadding(): number { - return this.config.labelPadding ?? 5 + @computed get tickPadding(): number { + return this.config.tickPadding ?? 5 + } + + @computed get labelPosition(): AxisAlign { + return this.config.labelPosition ?? AxisAlign.middle } @computed get nice(): boolean { @@ -488,7 +493,7 @@ abstract class AbstractAxis { const text = this.label return text ? new MarkdownTextWrap({ - maxWidth: this.labelWidth, + maxWidth: this.labelMaxWidth, fontSize: this.labelFontSize, text, lineHeight: 1, @@ -497,6 +502,12 @@ abstract class AbstractAxis { }) : undefined } + + @computed get labelHeight(): number { + return this.labelTextWrap + ? this.labelTextWrap.height + this.labelPadding + : 0 + } } export class HorizontalAxis extends AbstractAxis { @@ -511,13 +522,15 @@ export class HorizontalAxis extends AbstractAxis { : Position.bottom } + @computed get labelPadding(): number { + return this.config.labelPadding ?? 10 + } + @computed get labelOffset(): number { - return this.labelTextWrap - ? this.labelTextWrap.height + this.labelPadding * 2 - : 0 + return this.labelHeight } - @computed get labelWidth(): number { + @computed get labelMaxWidth(): number { return this.rangeSize } @@ -527,12 +540,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,14 +641,24 @@ export class VerticalAxis extends AbstractAxis { return Position.left } - @computed get labelWidth(): number { - return this.height + @computed get labelPadding(): number { + return this.config.labelPadding ?? 16 } - @computed get labelOffset(): number { - return this.labelTextWrap - ? this.labelTextWrap.height + this.labelPadding * 2 - : 0 + @computed get labelMaxWidth(): number { + // if rotated and positioned to the left of the axis, + // the label width is limited by the height of the axis + if (this.labelPosition === AxisAlign.middle) return this.height + + return this.axisManager?.axisBounds?.width ?? Infinity + } + + @computed get labelOffsetLeft(): number { + return this.labelPosition === AxisAlign.middle ? this.labelHeight : 0 + } + + @computed get labelOffsetTop(): number { + return this.labelPosition === AxisAlign.middle ? 0 : this.labelHeight } // note that we intentionally don't take `hideAxisLabels` into account here. @@ -646,13 +667,11 @@ 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, labelOffsetLeft } = 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 + return Math.max(tickWidth + labelOffsetLeft, this.config.minSize ?? 0) } @computed get height(): number { @@ -768,10 +787,17 @@ export class DualAxis { // 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 + // add padding to account for the width of the vertical axis + // and the height of the horizontal axis + .pad({ + [this.props.horizontalAxis.orient]: this.horizontalAxisSize, + [this.props.verticalAxis.orient]: this.verticalAxisSize, + }) + // make space for the y-axis label if plotted above the axis + .padTop(this.props.verticalAxis.labelOffsetTop) + ) } @computed get bounds(): Bounds { diff --git a/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts b/packages/@ourworldindata/grapher/src/axis/AxisConfig.ts index 676da96add0..56b88f145a3 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 = AxisAlign.middle @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 9f65ef78db9..9dc76473708 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" @@ -345,6 +346,10 @@ export class ScatterPlotChart ) } + @computed get axisBounds(): Bounds { + return this.innerBounds + } + @computed private get canAddCountry(): boolean { const { addCountryMode } = this.manager return (addCountryMode && @@ -538,7 +543,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, }) @@ -774,7 +779,7 @@ export class ScatterPlotChart const legendPadding = 16 const ySizeLegend = - bounds.top + + this.legendY + verticalLegendHeight + (verticalLegendHeight > 0 ? legendPadding : 0) const yArrowLegend = @@ -989,7 +994,7 @@ export class ScatterPlotChart } @computed get legendY(): number { - return this.bounds.top + return this.bounds.top + this.yAxis.labelHeight } @computed get legendX(): number { @@ -1029,15 +1034,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..92c60a4b0d7 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. + * For vertical axes, 'middle' rotates the label and places it to the left of 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