From 87cff5ec85298defcd240a1ec9c717b9ab706780 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 20 Dec 2024 14:20:52 +0100 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=94=A8=20drop=20manager=20pattern?= =?UTF-8?q?=20for=20horizontal=20color=20legend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/barCharts/DiscreteBarChart.tsx | 36 ++- .../grapher/src/chart/ChartInterface.ts | 14 +- .../grapher/src/facetChart/FacetChart.tsx | 185 +++++++------ .../HorizontalColorLegends.test.ts | 86 +++--- .../HorizontalColorLegends.tsx | 255 +++++++++++------- .../grapher/src/lineCharts/LineChart.test.ts | 9 +- .../grapher/src/lineCharts/LineChart.tsx | 63 +++-- .../grapher/src/mapCharts/MapChart.tsx | 99 +++++-- .../grapher/src/slopeCharts/SlopeChart.tsx | 6 +- .../stackedCharts/AbstractStackedChart.tsx | 6 +- .../src/stackedCharts/MarimekkoChart.tsx | 33 ++- .../stackedCharts/StackedAreaChart.test.ts | 8 +- .../src/stackedCharts/StackedBarChart.tsx | 32 ++- .../StackedDiscreteBarChart.test.ts | 12 +- .../stackedCharts/StackedDiscreteBarChart.tsx | 35 ++- 15 files changed, 543 insertions(+), 336 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 4b0857a882a..7d7d253d42e 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -71,8 +71,8 @@ import { } from "../color/ColorConstants" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { - HorizontalColorLegendManager, HorizontalNumericColorLegend, + HorizontalNumericColorLegendProps, } from "../horizontalColorLegend/HorizontalColorLegends" import { BaseType, Selection } from "d3" import { TextWrap } from "@ourworldindata/components" @@ -505,7 +505,7 @@ export class DiscreteBarChart <> {this.renderDefs()} {this.showColorLegend && ( - + )} {!this.isLogScale && ( bin instanceof CategoricalBin) } + @computed + private get legendProps(): HorizontalNumericColorLegendProps { + return { + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + legendMaxWidth: this.legendMaxWidth, + numericLegendData: this.numericLegendData, + numericBinSize: this.numericBinSize, + numericBinStroke: this.numericBinStroke, + equalSizeBins: this.equalSizeBins, + legendTitle: this.legendTitle, + numericLegendY: this.numericLegendY, + legendTextColor: this.legendTextColor, + legendTickSize: this.legendTickSize, + } + } + @computed get projectedDataColorInLegend(): string { // if a single color is in use, use that color in the legend if (uniqBy(this.series, "color").length === 1) @@ -823,7 +841,9 @@ export class DiscreteBarChart return DEFAULT_PROJECTED_DATA_COLOR_IN_LEGEND } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalNumericLegend(): + | HorizontalNumericColorLegendProps + | undefined { if (this.hasColorLegend) { return { numericLegendData: this.numericLegendData, @@ -843,10 +863,10 @@ export class DiscreteBarChart legendTextColor = "#555" legendTickSize = 1 - @computed get numericLegend(): HorizontalNumericColorLegend | undefined { + @computed get legendHeight(): number { return this.hasColorScale && this.manager.showLegend - ? new HorizontalNumericColorLegend({ manager: this }) - : undefined + ? HorizontalNumericColorLegend.height(this.legendProps) + : 0 } @computed get numericLegendY(): number { @@ -859,10 +879,6 @@ export class DiscreteBarChart : undefined } - @computed get legendHeight(): number { - return this.numericLegend?.height ?? 0 - } - // End of color legend props @computed get series(): DiscreteBarSeries[] { diff --git a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts index cfd81654f90..669a7558f8f 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts @@ -7,7 +7,10 @@ import { } from "@ourworldindata/types" import { ColorScale } from "../color/ColorScale" import { HorizontalAxis, VerticalAxis } from "../axis/Axis" -import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" +import { + HorizontalCategoricalColorLegendProps, + HorizontalNumericColorLegendProps, +} from "../horizontalColorLegend/HorizontalColorLegends" // The idea of this interface is to try and start reusing more code across our Chart classes and make it easier // for a dev to work on a chart type they haven't touched before if they've worked with another that implements // this interface. @@ -41,9 +44,14 @@ export interface ChartInterface { /** * The legend that has been hidden from the chart plot (using `manager.hideLegend`). - * Used to create a global legend for faceted charts. + * Used to create a global categorical legend for faceted charts. + */ + externalCategoricalLegend?: HorizontalCategoricalColorLegendProps + /** + * The legend that has been hidden from the chart plot (using `manager.hideLegend`). + * Used to create a global numeric legend for faceted charts. */ - externalLegend?: HorizontalColorLegendManager + externalNumericLegend?: HorizontalNumericColorLegendProps /** * Which facet strategies the chart type finds reasonable in its current setting, if any. diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index e66b7faa5b1..7e0aac0a036 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -55,9 +55,11 @@ import { AxisConfig } from "../axis/AxisConfig" import { HorizontalAxis, VerticalAxis } from "../axis/Axis" import { HorizontalCategoricalColorLegend, + HorizontalCategoricalColorLegendProps, HorizontalColorLegend, - HorizontalColorLegendManager, + HorizontalColorLegendProps, HorizontalNumericColorLegend, + HorizontalNumericColorLegendProps, } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, @@ -118,7 +120,7 @@ interface AxesInfo { @observer export class FacetChart extends React.Component - implements ChartInterface, HorizontalColorLegendManager + implements ChartInterface { transformTable(table: OwidTable): OwidTable { return table @@ -589,26 +591,28 @@ export class FacetChart // legend utils - @computed private get externalLegends(): HorizontalColorLegendManager[] { + @computed + private get externalCategoricalLegends(): HorizontalCategoricalColorLegendProps[] { return excludeUndefined( this.intermediateChartInstances.map( - (instance) => instance.externalLegend + (instance) => instance.externalCategoricalLegend ) ) } - @computed private get isNumericLegend(): boolean { - return this.externalLegends.some((legend) => - legend.numericLegendData?.some((bin) => bin instanceof NumericBin) + @computed + private get externalNumericLegends(): HorizontalNumericColorLegendProps[] { + return excludeUndefined( + this.intermediateChartInstances.map( + (instance) => instance.externalNumericLegend + ) ) } - @computed private get LegendClass(): - | typeof HorizontalNumericColorLegend - | typeof HorizontalCategoricalColorLegend { - return this.isNumericLegend - ? HorizontalNumericColorLegend - : HorizontalCategoricalColorLegend + @computed private get isNumericLegend(): boolean { + return this.externalNumericLegends.some((legend) => + legend.numericLegendData.some((bin) => bin instanceof NumericBin) + ) } @computed private get showLegend(): boolean { @@ -641,10 +645,21 @@ export class FacetChart return false } - private getExternalLegendProp< - Prop extends keyof HorizontalColorLegendManager, - >(prop: Prop): HorizontalColorLegendManager[Prop] | undefined { - for (const externalLegend of this.externalLegends) { + private getCategoricalExternalLegendProp< + Prop extends keyof HorizontalCategoricalColorLegendProps, + >(prop: Prop): HorizontalCategoricalColorLegendProps[Prop] | undefined { + for (const externalLegend of this.externalCategoricalLegends) { + if (externalLegend[prop] !== undefined) { + return externalLegend[prop] + } + } + return undefined + } + + private getNumericExternalLegendProp< + Prop extends keyof HorizontalNumericColorLegendProps, + >(prop: Prop): HorizontalNumericColorLegendProps[Prop] | undefined { + for (const externalLegend of this.externalNumericLegends) { if (externalLegend[prop] !== undefined) { return externalLegend[prop] } @@ -667,64 +682,50 @@ export class FacetChart // legend props - @computed get legendX(): number { - return this.bounds.x - } - - @computed get numericLegendY(): number { - return this.bounds.top - } - - @computed get categoryLegendY(): number { - return this.bounds.top - } - - @computed get legendMaxWidth(): number { - return this.bounds.width - } - - @computed get legendAlign(): HorizontalAlign { - return HorizontalAlign.left - } - - @computed get legendTitle(): string | undefined { - return this.getExternalLegendProp("legendTitle") - } - - @computed get legendHeight(): number | undefined { - return this.getExternalLegendProp("legendHeight") - } - - @computed get legendOpacity(): number | undefined { - return this.getExternalLegendProp("legendOpacity") - } - - @computed get legendTextColor(): Color | undefined { - return this.getExternalLegendProp("legendTextColor") - } - - @computed get legendTickSize(): number | undefined { - return this.getExternalLegendProp("legendTickSize") - } - - @computed get categoricalBinStroke(): Color | undefined { - return this.getExternalLegendProp("categoricalBinStroke") - } - - @computed get numericBinSize(): number | undefined { - return this.getExternalLegendProp("numericBinSize") - } - - @computed get numericBinStroke(): Color | undefined { - return this.getExternalLegendProp("numericBinStroke") + @computed private get commonLegendProps(): HorizontalColorLegendProps { + return { + fontSize: this.fontSize, + legendX: this.bounds.x, + legendMaxWidth: this.bounds.width, + legendAlign: HorizontalAlign.left, + onLegendMouseOver: this.onLegendMouseOver, + onLegendMouseLeave: this.onLegendMouseLeave, + } } - @computed get numericBinStrokeWidth(): number | undefined { - return this.getExternalLegendProp("numericBinStrokeWidth") + @computed + private get numericLegendProps(): HorizontalNumericColorLegendProps { + return { + ...this.commonLegendProps, + numericLegendY: this.bounds.top, + legendTitle: this.getNumericExternalLegendProp("legendTitle"), + legendTextColor: + this.getNumericExternalLegendProp("legendTextColor"), + legendTickSize: this.getNumericExternalLegendProp("legendTickSize"), + numericBinSize: this.getNumericExternalLegendProp("numericBinSize"), + numericBinStroke: + this.getNumericExternalLegendProp("numericBinStroke"), + numericBinStrokeWidth: this.getNumericExternalLegendProp( + "numericBinStrokeWidth" + ), + equalSizeBins: this.getNumericExternalLegendProp("equalSizeBins"), + numericLegendData: this.numericLegendData, + } } - @computed get equalSizeBins(): boolean | undefined { - return this.getExternalLegendProp("equalSizeBins") + @computed + private get categoricalLegendProps(): HorizontalCategoricalColorLegendProps { + return { + ...this.commonLegendProps, + categoryLegendY: this.bounds.top, + categoricalBinStroke: this.getCategoricalExternalLegendProp( + "categoricalBinStroke" + ), + hoverColors: this.hoverColors, + activeColors: this.activeColors, + categoricalLegendData: this.categoricalLegendData, + onLegendClick: this.onLegendClick, + } } @computed get hoverColors(): Color[] | undefined { @@ -750,13 +751,14 @@ export class FacetChart @computed get numericLegendData(): ColorScaleBin[] { if (!this.isNumericLegend || !this.hideFacetLegends) return [] - const allBins: ColorScaleBin[] = this.externalLegends.flatMap( - (legend) => [ - ...(legend.numericLegendData ?? []), - ...(legend.categoricalLegendData ?? []), - ] - ) - const uniqBins = this.getUniqBins(allBins) + const uniqBins = this.getUniqBins([ + ...this.externalCategoricalLegends.flatMap( + (legend) => legend.categoricalLegendData + ), + ...this.externalNumericLegends.flatMap( + (legend) => legend.numericLegendData + ), + ]) const sortedBins = sortBy( uniqBins, (bin) => bin instanceof CategoricalBin @@ -766,12 +768,9 @@ export class FacetChart @computed get categoricalLegendData(): CategoricalBin[] { if (this.isNumericLegend || !this.hideFacetLegends) return [] - const allBins: CategoricalBin[] = this.externalLegends - .flatMap((legend) => [ - ...(legend.numericLegendData ?? []), - ...(legend.categoricalLegendData ?? []), - ]) - .filter((bin) => bin instanceof CategoricalBin) as CategoricalBin[] + const allBins = this.externalCategoricalLegends.flatMap( + (legend) => legend.categoricalLegendData + ) const uniqBins = this.getUniqBins(allBins) const newBins = uniqBins.map( // remap index to ensure it's unique (the above procedure can lead to duplicates) @@ -815,7 +814,9 @@ export class FacetChart // end of legend props @computed private get legend(): HorizontalColorLegend { - return new this.LegendClass({ manager: this }) + return this.isNumericLegend + ? new HorizontalNumericColorLegend(this.numericLegendProps) + : new HorizontalCategoricalColorLegend(this.categoricalLegendProps) } @computed private get isFocusModeSupported(): boolean { @@ -866,11 +867,21 @@ export class FacetChart return { fontSize, shortenedLabel: label } } + private renderLegend(): React.ReactElement { + return this.isNumericLegend ? ( + + ) : ( + + ) + } + render(): React.ReactElement { - const { facetFontSize, LegendClass, showLegend } = this + const { facetFontSize, showLegend } = this return ( - {showLegend && } + {showLegend && this.renderLegend()} {this.placedSeries.map((facetChart, index: number) => { const ChartClass = ChartComponentClassMap.get(this.chartTypeName) ?? diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts index bc38e848369..3a420462480 100755 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts @@ -21,55 +21,53 @@ describe(HorizontalNumericColorLegend, () => { }) const legend = new HorizontalNumericColorLegend({ - manager: { numericLegendData: [bin] }, + numericLegendData: [bin], }) expect(legend.height).toBeGreaterThan(0) }) it("adds margins between categorical but not numeric bins", () => { const legend = new HorizontalNumericColorLegend({ - manager: { - numericLegendData: [ - new CategoricalBin({ - index: 0, - value: "a", - label: "a", - color: "#fff", - }), - new CategoricalBin({ - index: 0, - value: "b", - label: "b", - color: "#fff", - }), - new NumericBin({ - isFirst: true, - isOpenLeft: false, - isOpenRight: false, - min: 0, - max: 1, - displayMin: "0", - displayMax: "1", - color: "#fff", - }), - new NumericBin({ - isFirst: false, - isOpenLeft: false, - isOpenRight: false, - min: 1, - max: 2, - displayMin: "1", - displayMax: "2", - color: "#fff", - }), - new CategoricalBin({ - index: 0, - value: "c", - label: "c", - color: "#fff", - }), - ], - }, + numericLegendData: [ + new CategoricalBin({ + index: 0, + value: "a", + label: "a", + color: "#fff", + }), + new CategoricalBin({ + index: 0, + value: "b", + label: "b", + color: "#fff", + }), + new NumericBin({ + isFirst: true, + isOpenLeft: false, + isOpenRight: false, + min: 0, + max: 1, + displayMin: "0", + displayMax: "1", + color: "#fff", + }), + new NumericBin({ + isFirst: false, + isOpenLeft: false, + isOpenRight: false, + min: 1, + max: 2, + displayMin: "1", + displayMax: "2", + color: "#fff", + }), + new CategoricalBin({ + index: 0, + value: "c", + label: "c", + color: "#fff", + }), + ], }) const margin = legend["itemMargin"] @@ -100,7 +98,7 @@ describe(HorizontalCategoricalColorLegend, () => { }) const legend = new HorizontalCategoricalColorLegend({ - manager: { categoricalLegendData: [bin] }, + categoricalLegendData: [bin], }) expect(legend.height).toBeGreaterThan(0) }) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx index 37ed0325865..666f9f4fcfa 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx @@ -65,40 +65,47 @@ interface MarkLine { marks: CategoricalMark[] } -// TODO unify properties across categorical & numeric legend. -// This would make multiple legends per chart less convenient (only used in Map), but we shouldn't -// be using multiple anyway – instead the numeric should also handle categorical bins too. -export interface HorizontalColorLegendManager { +export interface HorizontalColorLegendProps { fontSize?: number legendX?: number legendAlign?: HorizontalAlign - legendTitle?: string - categoryLegendY?: number - numericLegendY?: number + legendWidth?: number - legendMaxWidth?: number - legendHeight?: number legendOpacity?: number - legendTextColor?: Color - legendTickSize?: number - categoricalLegendData?: CategoricalBin[] - categoricalFocusBracket?: CategoricalBin - categoricalBinStroke?: Color - numericLegendData?: ColorScaleBin[] - numericFocusBracket?: ColorScaleBin - numericBinSize?: number - numericBinStroke?: Color - numericBinStrokeWidth?: number - equalSizeBins?: boolean + legendMaxWidth?: number + onLegendMouseLeave?: () => void onLegendMouseOver?: (d: ColorScaleBin) => void - onLegendClick?: (d: ColorScaleBin) => void - activeColors?: string[] // inactive colors are grayed out +} + +export interface HorizontalCategoricalColorLegendProps + extends HorizontalColorLegendProps { + categoricalLegendData: CategoricalBin[] + categoricalBinStroke?: Color + categoryLegendY?: number + focusColors?: string[] // focused colors are bolded hoverColors?: string[] // non-hovered colors are muted + activeColors?: string[] // inactive colors are grayed out + + onLegendClick?: (d: ColorScaleBin) => void isStatic?: boolean } +export interface HorizontalNumericColorLegendProps + extends HorizontalColorLegendProps { + numericLegendData: ColorScaleBin[] + numericBinSize?: number + numericBinStroke?: Color + numericBinStrokeWidth?: number + equalSizeBins?: boolean + legendTitle?: string + numericFocusBracket?: ColorScaleBin + numericLegendY?: number + legendTextColor?: Color + legendTickSize?: number +} + const DEFAULT_NUMERIC_BIN_SIZE = 10 const DEFAULT_NUMERIC_BIN_STROKE = "#333" const DEFAULT_NUMERIC_BIN_STROKE_WIDTH = 0.3 @@ -110,60 +117,82 @@ const FOCUS_BORDER_COLOR = "#111" const SPACE_BETWEEN_CATEGORICAL_BINS = 7 const MINIMUM_LABEL_DISTANCE = 5 -export abstract class HorizontalColorLegend extends React.Component<{ - manager: HorizontalColorLegendManager -}> { - @computed protected get manager(): HorizontalColorLegendManager { - return this.props.manager - } - +export abstract class HorizontalColorLegend< + Props extends HorizontalColorLegendProps = HorizontalColorLegendProps, +> extends React.Component { @computed protected get legendX(): number { - return this.manager.legendX ?? 0 + return this.props.legendX ?? 0 } - @computed protected get categoryLegendY(): number { - return this.manager.categoryLegendY ?? 0 + @computed protected get legendAlign(): HorizontalAlign { + // Assume center alignment if none specified, for backwards-compatibility + return this.props.legendAlign ?? HorizontalAlign.center } - @computed protected get numericLegendY(): number { - return this.manager.numericLegendY ?? 0 + @computed protected get fontSize(): number { + return this.props.fontSize ?? BASE_FONT_SIZE } @computed protected get legendMaxWidth(): number | undefined { - return this.manager.legendMaxWidth + return this.props.legendMaxWidth } - @computed protected get legendHeight(): number { - return this.manager.legendHeight ?? 200 - } + abstract get height(): number + abstract get width(): number +} - @computed protected get legendAlign(): HorizontalAlign { - // Assume center alignment if none specified, for backwards-compatibility - return this.manager.legendAlign ?? HorizontalAlign.center - } +@observer +export class HorizontalNumericColorLegend extends HorizontalColorLegend { + base: React.RefObject = React.createRef() - @computed protected get fontSize(): number { - return this.manager.fontSize ?? BASE_FONT_SIZE + static height( + props: Pick< + HorizontalNumericColorLegendProps, + | "numericLegendData" + | "numericBinSize" + | "fontSize" + | "legendWidth" + | "legendMaxWidth" + | "legendTitle" + | "equalSizeBins" + | "legendAlign" + | "legendX" + > + ): number { + const legend = new HorizontalNumericColorLegend(props) + return legend.height } - @computed protected get legendTextColor(): Color { - return this.manager.legendTextColor ?? DEFAULT_TEXT_COLOR + static width( + props: Pick< + HorizontalNumericColorLegendProps, + | "numericLegendData" + | "numericBinSize" + | "fontSize" + | "legendWidth" + | "legendMaxWidth" + | "legendTitle" + | "equalSizeBins" + > + ): number { + const legend = new HorizontalNumericColorLegend(props) + return legend.width } - @computed protected get legendTickSize(): number { - return this.manager.legendTickSize ?? DEFAULT_TICK_SIZE + @computed private get numericLegendY(): number { + return this.props.numericLegendY ?? 0 } - abstract get height(): number - abstract get width(): number -} + @computed private get legendTextColor(): Color { + return this.props.legendTextColor ?? DEFAULT_TEXT_COLOR + } -@observer -export class HorizontalNumericColorLegend extends HorizontalColorLegend { - base: React.RefObject = React.createRef() + @computed private get legendTickSize(): number { + return this.props.legendTickSize ?? DEFAULT_TICK_SIZE + } @computed private get numericLegendData(): ColorScaleBin[] { - return this.manager.numericLegendData ?? [] + return this.props.numericLegendData ?? [] } @computed private get visibleBins(): ColorScaleBin[] { @@ -177,17 +206,16 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { } @computed private get numericBinSize(): number { - return this.props.manager.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE + return this.props.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE } @computed private get numericBinStroke(): Color { - return this.props.manager.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE + return this.props.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE } @computed private get numericBinStrokeWidth(): number { return ( - this.props.manager.numericBinStrokeWidth ?? - DEFAULT_NUMERIC_BIN_STROKE_WIDTH + this.props.numericBinStrokeWidth ?? DEFAULT_NUMERIC_BIN_STROKE_WIDTH ) } @@ -212,7 +240,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { } @computed private get maxWidth(): number { - return this.manager.legendMaxWidth ?? this.manager.legendWidth ?? 200 + return this.props.legendMaxWidth ?? this.props.legendWidth ?? 200 } private getTickLabelWidth(label: string): number { @@ -240,8 +268,8 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { @computed private get isAutoWidth(): boolean { return ( - this.manager.legendWidth === undefined && - this.manager.legendMaxWidth !== undefined + this.props.legendWidth === undefined && + this.props.legendMaxWidth !== undefined ) } @@ -270,7 +298,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { shareOfTotal: (bin.max - bin.min) / this.rangeSize, })) // Make sure the legend is big enough to avoid overlapping labels (including `raisedMode`) - if (this.manager.equalSizeBins) { + if (this.props.equalSizeBins) { // Try to keep the minimum close to the size of the "No data" bin, // so they look visually balanced somewhat. const minBinWidth = this.fontSize * 3.25 @@ -326,7 +354,6 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { @computed private get positionedBins(): PositionedBin[] { const { - manager, rangeSize, availableNumericWidth, visibleBins, @@ -334,6 +361,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { legendTitleWidth, x, } = this + const { equalSizeBins } = this.props let xOffset = x + legendTitleWidth let prevBin: ColorScaleBin | undefined @@ -344,7 +372,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { let marginLeft: number = isFirst ? 0 : this.itemMargin if (bin instanceof NumericBin) { - if (manager.equalSizeBins) { + if (equalSizeBins) { width = availableNumericWidth / numericBins.length } else { width = @@ -374,7 +402,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { } @computed private get legendTitle(): TextWrap | undefined { - const { legendTitle } = this.manager + const { legendTitle } = this.props return legendTitle ? new TextWrap({ text: legendTitle, @@ -498,8 +526,9 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { } @action.bound private onMouseMove(ev: MouseEvent | TouchEvent): void { - const { manager, base, positionedBins } = this - const { numericFocusBracket } = manager + const { base, positionedBins } = this + const { numericFocusBracket, onLegendMouseLeave, onLegendMouseOver } = + this.props if (base.current) { const mouse = getRelativeMouse(base.current, ev) @@ -512,8 +541,8 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { // If outside legend bounds, trigger onMouseLeave if there is an existing bin in focus. if (!this.bounds.contains(mouse)) { - if (numericFocusBracket && manager.onLegendMouseLeave) - return manager.onLegendMouseLeave() + if (numericFocusBracket && onLegendMouseLeave) + return onLegendMouseLeave() return } @@ -524,8 +553,8 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { newFocusBracket = bin.bin }) - if (newFocusBracket && manager.onLegendMouseOver) - manager.onLegendMouseOver(newFocusBracket) + if (newFocusBracket && onLegendMouseOver) + onLegendMouseOver(newFocusBracket) } } @@ -546,14 +575,8 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { } render(): React.ReactElement { - const { - manager, - numericLabels, - numericBinSize, - positionedBins, - height, - } = this - const { numericFocusBracket } = manager + const { numericLabels, numericBinSize, positionedBins, height } = this + const { numericFocusBracket, legendOpacity } = this.props const stroke = this.numericBinStroke const strokeWidth = this.numericBinStrokeWidth @@ -604,7 +627,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { ? `url(#${bin.patternRef})` : bin.color } - opacity={manager.legendOpacity} // defaults to undefined which removes the prop + opacity={legendOpacity} // defaults to undefined which removes the prop stroke={ isFocus ? FOCUS_BORDER_COLOR : stroke } @@ -700,16 +723,47 @@ const NumericBinRect = (props: NumericBinRectProps) => { } @observer -export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { +export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { private rectPadding = 5 private markPadding = 5 + static height( + props: Pick< + HorizontalCategoricalColorLegendProps, + | "categoricalLegendData" + | "fontSize" + | "legendWidth" + | "legendMaxWidth" + | "legendAlign" + > + ): number { + const legend = new HorizontalCategoricalColorLegend(props) + return legend.height + } + + static numLines( + props: Pick< + HorizontalCategoricalColorLegendProps, + | "categoricalLegendData" + | "fontSize" + | "legendWidth" + | "legendMaxWidth" + > + ): number { + const legend = new HorizontalCategoricalColorLegend(props) + return legend.numLines + } + + @computed private get categoryLegendY(): number { + return this.props.categoryLegendY ?? 0 + } + @computed get width(): number { - return this.manager.legendWidth ?? this.manager.legendMaxWidth ?? 200 + return this.props.legendWidth ?? this.legendMaxWidth ?? 200 } @computed private get categoricalLegendData(): CategoricalBin[] { - return this.manager.categoricalLegendData ?? [] + return this.props.categoricalLegendData ?? [] } @computed private get visibleCategoricalBins(): CategoricalBin[] { @@ -814,8 +868,8 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { } renderLabels(): React.ReactElement { - const { manager, marks } = this - const { focusColors, hoverColors = [] } = manager + const { marks } = this + const { focusColors, hoverColors = [] } = this.props return ( @@ -847,8 +901,13 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { } renderSwatches(): React.ReactElement { - const { manager, marks } = this - const { activeColors, hoverColors = [] } = manager + const { marks } = this + const { + activeColors, + hoverColors = [], + legendOpacity, + categoricalBinStroke, + } = this.props return ( @@ -870,7 +929,7 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { const opacity = isNotHovered ? GRAPHER_OPACITY_MUTE - : manager.legendOpacity + : legendOpacity return ( @@ -892,21 +951,21 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { } renderInteractiveElements(): React.ReactElement { - const { manager, marks } = this + const { marks, props } = this return ( {marks.map((mark, index) => { const mouseOver = (): void => - manager.onLegendMouseOver - ? manager.onLegendMouseOver(mark.bin) + props.onLegendMouseOver + ? props.onLegendMouseOver(mark.bin) : undefined const mouseLeave = (): void => - manager.onLegendMouseLeave - ? manager.onLegendMouseLeave() + props.onLegendMouseLeave + ? props.onLegendMouseLeave() : undefined - const click = manager.onLegendClick - ? (): void => manager.onLegendClick?.(mark.bin) + const click = props.onLegendClick + ? (): void => props.onLegendClick?.(mark.bin) : undefined const cursor = click ? "pointer" : "default" @@ -949,7 +1008,7 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { > {this.renderSwatches()} {this.renderLabels()} - {!this.manager.isStatic && this.renderInteractiveElements()} + {!this.props.isStatic && this.renderInteractiveElements()} ) } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index bc77d3dee6f..42eef7e07db 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -293,16 +293,17 @@ describe("externalLegendBins", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart.externalCategoricalLegend).toBeUndefined() + expect(chart.externalNumericLegend).toBeUndefined() }) it("exposes externalLegendBins when legend is hidden", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart["externalLegend"]?.categoricalLegendData?.length).toEqual( - 2 - ) + expect( + chart.externalCategoricalLegend?.categoricalLegendData?.length + ).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index dd54175a663..04d4e9202a5 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -104,8 +104,9 @@ import { MultiColorPolyline } from "../scatterCharts/MultiColorPolyline" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" import { darkenColorForLine } from "../color/ColorUtils" import { - HorizontalColorLegendManager, + HorizontalCategoricalColorLegendProps, HorizontalNumericColorLegend, + HorizontalNumericColorLegendProps, } from "../horizontalColorLegend/HorizontalColorLegends" import { AnnotationsMap, @@ -344,11 +345,7 @@ export class LineChart bounds?: Bounds manager: LineChartManager }> - implements - ChartInterface, - AxisManager, - ColorScaleManager, - HorizontalColorLegendManager + implements ChartInterface, AxisManager, ColorScaleManager { base: React.RefObject = React.createRef() @@ -498,7 +495,7 @@ export class LineChart @computed private get boundsWithoutColorLegend(): Bounds { return this.bounds.padTop( - this.hasColorLegend ? this.legendHeight + LEGEND_PADDING : 0 + this.hasColorLegend ? this.colorLegendHeight + LEGEND_PADDING : 0 ) } @@ -931,7 +928,7 @@ export class LineChart renderColorLegend(): React.ReactElement | void { if (this.hasColorLegend) - return + return } /** @@ -1157,10 +1154,28 @@ export class LineChart return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } - @computed get numericLegend(): HorizontalNumericColorLegend | undefined { + @computed get colorLegendHeight(): number { return this.hasColorScale && this.manager.showLegend - ? new HorizontalNumericColorLegend({ manager: this }) - : undefined + ? HorizontalNumericColorLegend.height(this.colorLegendProps) + : 0 + } + + get colorLegendProps(): HorizontalNumericColorLegendProps { + return { + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + legendMaxWidth: this.legendMaxWidth, + numericLegendData: this.numericLegendData, + numericBinSize: this.numericBinSize, + numericBinStroke: this.numericBinStroke, + numericBinStrokeWidth: this.numericBinStrokeWidth, + equalSizeBins: this.equalSizeBins, + legendTitle: this.legendTitle, + numericLegendY: this.numericLegendY, + legendTextColor: this.legendTextColor, + legendTickSize: this.legendTickSize, + } } @computed get numericLegendY(): number { @@ -1173,10 +1188,6 @@ export class LineChart : undefined } - @computed get legendHeight(): number { - return this.numericLegend?.height ?? 0 - } - // End of color legend props @computed private get annotationsMap(): AnnotationsMap | undefined { @@ -1467,11 +1478,10 @@ export class LineChart return this.dualAxis.horizontalAxis } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalCategoricalLegend(): + | HorizontalCategoricalColorLegendProps + | undefined { if (!this.manager.showLegend) { - const numericLegendData = this.hasColorScale - ? this.numericLegendData - : [] const categoricalLegendData = this.hasColorScale ? [] : this.series.map( @@ -1483,6 +1493,20 @@ export class LineChart color: series.color, }) ) + return { + categoricalLegendData, + } + } + return undefined + } + + @computed get externalNumericLegend(): + | HorizontalNumericColorLegendProps + | undefined { + if (!this.manager.showLegend) { + const numericLegendData = this.hasColorScale + ? this.numericLegendData + : [] return { legendTitle: this.legendTitle, legendTextColor: this.legendTextColor, @@ -1492,7 +1516,6 @@ export class LineChart numericBinStroke: this.numericBinStroke, numericBinStrokeWidth: this.numericBinStrokeWidth, numericLegendData, - categoricalLegendData, } } return undefined diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 6a3c29eb8c7..bfbcc27d1e8 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -18,7 +18,6 @@ import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, HorizontalNumericColorLegend, } from "../horizontalColorLegend/HorizontalColorLegends" import { MapProjectionGeos } from "./MapProjections" @@ -163,7 +162,7 @@ const renderFeaturesFor = ( @observer export class MapChart extends React.Component - implements ChartInterface, HorizontalColorLegendManager, ColorScaleManager + implements ChartInterface, ColorScaleManager { @observable focusEntity?: MapEntity @observable focusBracket?: MapBracket @@ -525,50 +524,72 @@ export class MapChart return this.categoryLegendHeight + this.numericLegendHeight + 10 } - @computed get numericLegendHeight(): number { - return this.numericLegend ? this.numericLegend.height : 0 + @computed private get hasCategoryLegend(): boolean { + return this.categoricalLegendData.length > 1 } - @computed get categoryLegendHeight(): number { - return this.categoryLegend ? this.categoryLegend.height + 5 : 0 + @computed private get hasNumericLegend(): boolean { + return this.numericLegendData.length > 1 } - @computed get categoryLegend(): - | HorizontalCategoricalColorLegend - | undefined { - return this.categoricalLegendData.length > 1 - ? new HorizontalCategoricalColorLegend({ manager: this }) - : undefined + @computed get categoryLegendHeight(): number { + return this.hasCategoryLegend + ? HorizontalCategoricalColorLegend.height({ + fontSize: this.fontSize, + legendAlign: this.legendAlign, + legendMaxWidth: this.legendMaxWidth, + categoricalLegendData: this.categoricalLegendData, + }) + 5 + : 0 + } + + @computed get categoryLegendNumLines(): number { + return this.hasCategoryLegend + ? HorizontalCategoricalColorLegend.numLines({ + fontSize: this.fontSize, + legendMaxWidth: this.legendMaxWidth, + categoricalLegendData: this.categoricalLegendData, + }) + : 0 } - @computed get numericLegend(): HorizontalNumericColorLegend | undefined { - return this.numericLegendData.length > 1 - ? new HorizontalNumericColorLegend({ manager: this }) - : undefined + @computed get numericLegendHeight(): number { + return this.hasNumericLegend + ? HorizontalNumericColorLegend.height({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + legendMaxWidth: this.legendMaxWidth, + numericLegendData: this.numericLegendData, + equalSizeBins: this.equalSizeBins, + }) + : 0 } @computed get categoryLegendY(): number { - const { categoryLegend, bounds, categoryLegendHeight } = this + const { hasCategoryLegend, bounds, categoryLegendHeight } = this - if (categoryLegend) return bounds.bottom - categoryLegendHeight + if (hasCategoryLegend) return bounds.bottom - categoryLegendHeight return 0 } @computed get legendAlign(): HorizontalAlign { - if (this.numericLegend) return HorizontalAlign.center - const { numLines = 0 } = this.categoryLegend ?? {} - return numLines > 1 ? HorizontalAlign.left : HorizontalAlign.center + if (this.hasNumericLegend) return HorizontalAlign.center + + return this.categoryLegendNumLines > 1 + ? HorizontalAlign.left + : HorizontalAlign.center } @computed get numericLegendY(): number { const { - numericLegend, + hasNumericLegend, numericLegendHeight, bounds, categoryLegendHeight, } = this - if (numericLegend) + if (hasNumericLegend) return ( bounds.bottom - categoryLegendHeight - numericLegendHeight - 4 ) @@ -588,15 +609,37 @@ export class MapChart } renderMapLegend(): React.ReactElement { - const { numericLegend, categoryLegend } = this + const { hasNumericLegend, hasCategoryLegend } = this return ( <> - {numericLegend && ( - + {hasNumericLegend && ( + )} - {categoryLegend && ( - + {hasCategoryLegend && ( + )} ) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index b713209ebf9..b3e044dd93d 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -86,7 +86,7 @@ import { } from "../lineCharts/LineChartHelpers" import { SelectionArray } from "../selection/SelectionArray" import { Halo } from "@ourworldindata/components" -import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" +import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin } from "../color/ColorScaleBin" import { OWID_NON_FOCUSED_GRAY, @@ -591,7 +591,9 @@ export class SlopeChart : 0 } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalCategoricalLegend(): + | HorizontalCategoricalColorLegendProps + | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series.map( (series, index) => diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index 5b40a3086bb..e6824d85d18 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -38,7 +38,7 @@ import { select } from "d3-selection" import { ColorSchemes } from "../color/ColorSchemes" import { SelectionArray } from "../selection/SelectionArray" import { CategoricalBin } from "../color/ColorScaleBin" -import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" +import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalColorAssigner, CategoricalColorMap, @@ -436,7 +436,9 @@ export class AbstractStackedChart return this.unstackedSeries } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalCategoricalLegend(): + | HorizontalCategoricalColorLegendProps + | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series .map( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index c95f9cc3c72..7de3e4b740c 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -58,10 +58,7 @@ import { makeTooltipRoundingNotice, makeTooltipToleranceNotice, } from "../tooltip/Tooltip" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, -} from "../horizontalColorLegend/HorizontalColorLegends" +import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" import { ColorScale, ColorScaleManager } from "../color/ColorScale" @@ -262,7 +259,7 @@ export class MarimekkoChart manager: MarimekkoChartManager containerElement?: HTMLDivElement }> - implements ChartInterface, HorizontalColorLegendManager, ColorScaleManager + implements ChartInterface, ColorScaleManager { base: React.RefObject = React.createRef() @@ -551,7 +548,7 @@ export class MarimekkoChart return this.bounds .padBottom(this.longestLabelHeight + 2) .padBottom(labelLinesHeight) - .padTop(this.legend.height + this.legendPaddingTop) + .padTop(this.legendHeight + this.legendPaddingTop) .padLeft(marginToEnsureWidestEntityLabelFitsEvenIfAtX0) } @@ -838,7 +835,7 @@ export class MarimekkoChart // legend props @computed get legendPaddingTop(): number { - return this.legend.height > 0 ? this.baseFontSize : 0 + return this.legendHeight > 0 ? this.baseFontSize : 0 } @computed get legendX(): number { @@ -897,8 +894,13 @@ export class MarimekkoChart this.focusColorBin = undefined } - @computed private get legend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + @computed private get legendHeight(): number { + return HorizontalCategoricalColorLegend.height({ + fontSize: this.fontSize, + legendAlign: this.legendAlign, + legendWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) } @computed private get formatColumn(): CoreColumn { @@ -1053,7 +1055,18 @@ export class MarimekkoChart } detailsMarker={manager.detailsMarkerInSvg} /> - + {this.renderBars()} {target && ( { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart.externalCategoricalLegend).toBeUndefined() }) it("exposes externalLegendBins when legend is hidden", () => { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart["externalLegend"]?.categoricalLegendData?.length).toEqual( - 2 - ) + expect( + chart.externalCategoricalLegend?.categoricalLegendData?.length + ).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 0224e532810..5072d185e35 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -211,7 +211,7 @@ export class StackedBarChart @computed protected get paddingForLegendTop(): number { return this.showHorizontalLegend - ? this.horizontalColorLegend.height + 8 + ? this.horizontalColorLegendHeight + 8 : 0 } @@ -307,13 +307,9 @@ export class StackedBarChart @computed get sidebarWidth(): number { if (!this.manager.showLegend) return 0 - const { - sidebarMinWidth, - sidebarMaxWidth, - verticalColorLegend: legendDimensions, - } = this + const { sidebarMinWidth, sidebarMaxWidth, verticalColorLegend } = this return Math.max( - Math.min(legendDimensions.width, sidebarMaxWidth), + Math.min(verticalColorLegend.width, sidebarMaxWidth), sidebarMinWidth ) } @@ -329,8 +325,13 @@ export class StackedBarChart } @computed - private get horizontalColorLegend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + private get horizontalColorLegendHeight(): number { + return HorizontalCategoricalColorLegend.height({ + fontSize: this.fontSize, + legendAlign: this.legendAlign, + legendWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) } @computed get formatColumn(): CoreColumn { @@ -485,7 +486,18 @@ export class StackedBarChart const y = this.bounds.top return showHorizontalLegend ? ( - + ) : ( { const chart = new StackedDiscreteBarChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["legend"].height).toBeGreaterThan(0) + expect(chart["legendHeight"]).toBeGreaterThan(0) expect(chart["categoricalLegendData"].length).toBeGreaterThan(0) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart["externalCategoricalLegend"]).toBeUndefined() }) it("exposes externalLegendBins when showLegend is false", () => { const chart = new StackedDiscreteBarChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart["legend"].height).toEqual(0) + expect(chart["legendHeight"]).toEqual(0) expect(chart["categoricalLegendData"].length).toEqual(0) - expect(chart["externalLegend"]?.categoricalLegendData?.length).toEqual( - 2 - ) + expect( + chart["externalCategoricalLegend"]?.categoricalLegendData?.length + ).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index 63317a0eae4..31b0434d7d5 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -68,7 +68,7 @@ import { StackedPoint, StackedSeries } from "./StackedConstants" import { ColorSchemes } from "../color/ColorSchemes" import { HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, + HorizontalCategoricalColorLegendProps, } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { isDarkColor } from "../color/ColorUtils" @@ -131,7 +131,7 @@ export class StackedDiscreteBarChart manager: StackedDiscreteBarChartManager containerElement?: HTMLDivElement }> - implements ChartInterface, HorizontalColorLegendManager + implements ChartInterface { base: React.RefObject = React.createRef() @@ -330,8 +330,8 @@ export class StackedDiscreteBarChart @computed private get boundsWithoutLegend(): Bounds { return this.bounds.padTop( - this.showLegend && this.legend.height > 0 - ? this.legend.height + this.legendPaddingTop + this.showLegend && this.legendHeight > 0 + ? this.legendHeight + this.legendPaddingTop : 0 ) } @@ -525,7 +525,9 @@ export class StackedDiscreteBarChart return this.showLegend ? this.legendBins : [] } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalCategoricalLegend(): + | HorizontalCategoricalColorLegendProps + | undefined { if (!this.showLegend) { return { categoricalLegendData: this.legendBins, @@ -546,8 +548,13 @@ export class StackedDiscreteBarChart this.focusSeriesName = undefined } - @computed private get legend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + @computed private get legendHeight(): number { + return HorizontalCategoricalColorLegend.height({ + fontSize: this.fontSize, + legendAlign: this.legendAlign, + legendWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) } @computed private get formatColumn(): CoreColumn { @@ -724,7 +731,19 @@ export class StackedDiscreteBarChart renderLegend(): React.ReactElement | void { if (!this.showLegend) return - return + return ( + + ) } renderStatic(): React.ReactElement { From e2f61c8904d4a4542ae370bb94391bb617ef712b Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sat, 21 Dec 2024 12:24:39 +0100 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=94=A8=20separate=20state=20from=20?= =?UTF-8?q?rendering=20for=20horizontal=20color=20legends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/barCharts/DiscreteBarChart.tsx | 15 +- .../grapher/src/chart/ChartInterface.ts | 7 +- .../grapher/src/facetChart/FacetChart.tsx | 55 +- .../AbstractHorizontalColorLegend.ts | 39 + .../HorizontalCategoricalColorLegend.ts | 162 +++ ...izontalCategoricalColorLegendComponent.tsx | 181 +++ .../HorizontalColorLegendConstants.ts | 9 + .../HorizontalColorLegends.test.ts | 4 +- .../HorizontalColorLegends.tsx | 1015 ----------------- .../HorizontalNumericColorLegend.ts | 409 +++++++ .../HorizontalNumericColorLegendComponent.tsx | 229 ++++ .../grapher/src/lineCharts/LineChart.tsx | 23 +- .../grapher/src/mapCharts/MapChart.tsx | 69 +- .../grapher/src/slopeCharts/SlopeChart.tsx | 2 +- .../stackedCharts/AbstractStackedChart.tsx | 2 +- .../src/stackedCharts/MarimekkoChart.tsx | 26 +- .../src/stackedCharts/StackedBarChart.tsx | 37 +- .../stackedCharts/StackedDiscreteBarChart.tsx | 32 +- 18 files changed, 1199 insertions(+), 1117 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegendConstants.ts delete mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts create mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 7d7d253d42e..8a1f921e6b4 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -70,12 +70,13 @@ import { OWID_NO_DATA_GRAY, } from "../color/ColorConstants" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" +import { BaseType, Selection } from "d3" +import { TextWrap } from "@ourworldindata/components" import { HorizontalNumericColorLegend, HorizontalNumericColorLegendProps, -} from "../horizontalColorLegend/HorizontalColorLegends" -import { BaseType, Selection } from "d3" -import { TextWrap } from "@ourworldindata/components" +} from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const labelToTextPadding = 10 const labelToBarPadding = 5 @@ -505,7 +506,9 @@ export class DiscreteBarChart <> {this.renderDefs()} {this.showColorLegend && ( - + )} {!this.isLogScale && ( + ) : ( - ) } diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts new file mode 100644 index 00000000000..e519bdd69a8 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts @@ -0,0 +1,39 @@ +import { computed } from "mobx" +import { HorizontalAlign } from "@ourworldindata/types" +import { BASE_FONT_SIZE } from "../core/GrapherConstants" + +export interface HorizontalColorLegendProps { + fontSize?: number + legendX?: number + legendAlign?: HorizontalAlign + legendMaxWidth?: number +} + +export abstract class AbstractHorizontalColorLegend< + Props extends HorizontalColorLegendProps, +> { + props: Props + constructor(props: Props) { + this.props = props + } + + @computed get legendX(): number { + return this.props.legendX ?? 0 + } + + @computed protected get legendAlign(): HorizontalAlign { + // Assume center alignment if none specified, for backwards-compatibility + return this.props.legendAlign ?? HorizontalAlign.center + } + + @computed protected get fontSize(): number { + return this.props.fontSize ?? BASE_FONT_SIZE + } + + @computed protected get legendMaxWidth(): number | undefined { + return this.props.legendMaxWidth + } + + abstract get height(): number + abstract get width(): number +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts new file mode 100644 index 00000000000..62dce0aa8c5 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -0,0 +1,162 @@ +import { computed } from "mobx" +import { max, Bounds, Color, HorizontalAlign } from "@ourworldindata/utils" +import { CategoricalBin } from "../color/ColorScaleBin" +import { GRAPHER_FONT_SCALE_12_8 } from "../core/GrapherConstants" +import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" +import { + AbstractHorizontalColorLegend, + HorizontalColorLegendProps, +} from "./AbstractHorizontalColorLegend" + +export interface HorizontalCategoricalColorLegendProps + extends HorizontalColorLegendProps { + categoricalLegendData: CategoricalBin[] + categoricalBinStroke?: Color + categoryLegendY?: number +} + +interface CategoricalMark { + x: number + y: number + rectSize: number + width: number + label: { + text: string + bounds: Bounds + fontSize: number + } + bin: CategoricalBin +} + +interface MarkLine { + totalWidth: number + marks: CategoricalMark[] +} + +export class HorizontalCategoricalColorLegend extends AbstractHorizontalColorLegend { + rectPadding = 5 + private markPadding = 5 + + static height(props: HorizontalCategoricalColorLegendProps): number { + const legend = new HorizontalCategoricalColorLegend(props) + return legend.height + } + + static numLines(props: HorizontalCategoricalColorLegendProps): number { + const legend = new HorizontalCategoricalColorLegend(props) + return legend.numLines + } + + @computed get categoryLegendY(): number { + return this.props.categoryLegendY ?? 0 + } + + @computed get width(): number { + return this.legendMaxWidth ?? 200 + } + + @computed private get categoricalLegendData(): CategoricalBin[] { + return this.props.categoricalLegendData ?? [] + } + + @computed private get visibleCategoricalBins(): CategoricalBin[] { + return this.categoricalLegendData.filter((bin) => !bin.isHidden) + } + + @computed private get markLines(): MarkLine[] { + const fontSize = this.fontSize * GRAPHER_FONT_SCALE_12_8 + const rectSize = this.fontSize * 0.75 + + const lines: MarkLine[] = [] + let marks: CategoricalMark[] = [] + let xOffset = 0 + let yOffset = 0 + this.visibleCategoricalBins.forEach((bin) => { + const labelBounds = Bounds.forText(bin.text, { fontSize }) + const markWidth = + rectSize + + this.rectPadding + + labelBounds.width + + this.markPadding + + if (xOffset + markWidth > this.width && marks.length > 0) { + lines.push({ + totalWidth: xOffset - this.markPadding, + marks: marks, + }) + marks = [] + xOffset = 0 + yOffset += rectSize + this.rectPadding + } + + const markX = xOffset + const markY = yOffset + + const label = { + text: bin.text, + bounds: labelBounds.set({ + x: markX + rectSize + this.rectPadding, + y: markY + rectSize / 2, + }), + fontSize, + } + + marks.push({ + x: markX, + y: markY, + width: markWidth, + rectSize, + label, + bin, + }) + + xOffset += markWidth + SPACE_BETWEEN_CATEGORICAL_BINS + }) + + if (marks.length > 0) + lines.push({ totalWidth: xOffset - this.markPadding, marks: marks }) + + return lines + } + + @computed private get contentWidth(): number { + return max(this.markLines.map((l) => l.totalWidth)) as number + } + + @computed private get containerWidth(): number { + return this.width ?? this.contentWidth + } + + @computed get marks(): CategoricalMark[] { + const lines = this.markLines + const align = this.legendAlign + const width = this.containerWidth + + // Center each line + lines.forEach((line) => { + // TODO abstract this + const xShift = + align === HorizontalAlign.center + ? (width - line.totalWidth) / 2 + : align === HorizontalAlign.right + ? width - line.totalWidth + : 0 + line.marks.forEach((mark) => { + mark.x += xShift + mark.label.bounds = mark.label.bounds.set({ + x: mark.label.bounds.x + xShift, + }) + }) + }) + + return lines.flatMap((l) => l.marks) + } + + @computed get height(): number { + return max(this.marks.map((mark) => mark.y + mark.rectSize)) ?? 0 + } + + @computed get numLines(): number { + return this.markLines.length + } +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx new file mode 100644 index 00000000000..3e0432a23b7 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -0,0 +1,181 @@ +import React from "react" +import { HorizontalCategoricalColorLegend } from "./HorizontalCategoricalColorLegend" +import { computed } from "mobx" +import { observer } from "mobx-react" +import { + dyFromAlign, + makeIdForHumanConsumption, + VerticalAlign, +} from "@ourworldindata/utils" +import { GRAPHER_OPACITY_MUTE } from "../core/GrapherConstants" +import { OWID_NON_FOCUSED_GRAY } from "../color/ColorConstants" +import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" +import { ColorScaleBin } from "../color/ColorScaleBin" + +@observer +export class HorizontalCategoricalColorLegendComponent extends React.Component<{ + legend: HorizontalCategoricalColorLegend + legendOpacity?: number + onLegendMouseLeave?: () => void + onLegendMouseOver?: (d: ColorScaleBin) => void + onLegendClick?: (d: ColorScaleBin) => void + + focusColors?: string[] // focused colors are bolded + hoverColors?: string[] // non-hovered colors are muted + activeColors?: string[] // inactive colors are grayed out +}> { + @computed private get legend(): HorizontalCategoricalColorLegend { + return this.props.legend + } + + renderLabels(): React.ReactElement { + const { marks } = this.legend + const { focusColors, hoverColors = [] } = this.props + + return ( + + {marks.map((mark, index) => { + const isFocus = focusColors?.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && + !hoverColors.includes(mark.bin.color) + + return ( + + {mark.label.text} + + ) + })} + + ) + } + + renderSwatches(): React.ReactElement { + const { marks } = this.legend + const { categoricalBinStroke } = this.legend.props + const { legendOpacity, activeColors, hoverColors = [] } = this.props + + return ( + + {marks.map((mark, index) => { + const isActive = activeColors?.includes(mark.bin.color) + const isHovered = hoverColors.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && + !hoverColors.includes(mark.bin.color) + + const color = mark.bin.patternRef + ? `url(#${mark.bin.patternRef})` + : mark.bin.color + + const fill = + isHovered || isActive || activeColors === undefined + ? color + : OWID_NON_FOCUSED_GRAY + + const opacity = isNotHovered + ? GRAPHER_OPACITY_MUTE + : legendOpacity + + return ( + + ) + })} + + ) + } + + renderInteractiveElements(): React.ReactElement { + const { props } = this + const { marks } = this.legend + + return ( + + {marks.map((mark, index) => { + const mouseOver = (): void => + props.onLegendMouseOver + ? props.onLegendMouseOver(mark.bin) + : undefined + const mouseLeave = (): void => + props.onLegendMouseLeave + ? props.onLegendMouseLeave() + : undefined + const click = props.onLegendClick + ? (): void => props.onLegendClick?.(mark.bin) + : undefined + + const cursor = click ? "pointer" : "default" + + return ( + + {/* for hover interaction */} + + + ) + })} + + ) + } + + render(): React.ReactElement { + const isInteractive = + this.props.onLegendClick || + this.props.onLegendMouseOver || + this.props.onLegendMouseLeave + + return ( + + {this.renderSwatches()} + {this.renderLabels()} + {isInteractive && this.renderInteractiveElements()} + + ) + } +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegendConstants.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegendConstants.ts new file mode 100644 index 00000000000..3263bd7c845 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegendConstants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_NUMERIC_BIN_SIZE = 10 +export const DEFAULT_NUMERIC_BIN_STROKE = "#333" +export const DEFAULT_NUMERIC_BIN_STROKE_WIDTH = 0.3 +export const DEFAULT_TEXT_COLOR = "#111" +export const DEFAULT_TICK_SIZE = 3 + +export const CATEGORICAL_BIN_MIN_WIDTH = 20 +export const SPACE_BETWEEN_CATEGORICAL_BINS = 7 +export const MINIMUM_LABEL_DISTANCE = 5 diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts index 3a420462480..7f5d30c2a21 100755 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.test.ts @@ -1,11 +1,11 @@ #! /usr/bin/env jest import { CategoricalBin, NumericBin } from "../color/ColorScaleBin" +import { HorizontalCategoricalColorLegend } from "./HorizontalCategoricalColorLegend" import { - HorizontalCategoricalColorLegend, HorizontalNumericColorLegend, PositionedBin, -} from "./HorizontalColorLegends" +} from "./HorizontalNumericColorLegend" describe(HorizontalNumericColorLegend, () => { it("can create one", () => { diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx deleted file mode 100644 index 666f9f4fcfa..00000000000 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx +++ /dev/null @@ -1,1015 +0,0 @@ -import React from "react" -import { action, computed } from "mobx" -import { observer } from "mobx-react" -import { - getRelativeMouse, - sortBy, - min, - max, - last, - sum, - dyFromAlign, - removeAllWhitespace, - Bounds, - Color, - HorizontalAlign, - VerticalAlign, - makeIdForHumanConsumption, -} from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" -import { - ColorScaleBin, - NumericBin, - CategoricalBin, -} from "../color/ColorScaleBin" -import { - BASE_FONT_SIZE, - GRAPHER_FONT_SCALE_12, - GRAPHER_FONT_SCALE_12_8, - GRAPHER_FONT_SCALE_14, - GRAPHER_OPACITY_MUTE, -} from "../core/GrapherConstants" -import { darkenColorForLine } from "../color/ColorUtils" -import { OWID_NON_FOCUSED_GRAY } from "../color/ColorConstants" - -export interface PositionedBin { - x: number - width: number - bin: ColorScaleBin -} - -interface NumericLabel { - text: string - fontSize: number - bounds: Bounds - priority?: boolean - hidden: boolean - raised: boolean -} - -interface CategoricalMark { - x: number - y: number - rectSize: number - width: number - label: { - text: string - bounds: Bounds - fontSize: number - } - bin: CategoricalBin -} - -interface MarkLine { - totalWidth: number - marks: CategoricalMark[] -} - -export interface HorizontalColorLegendProps { - fontSize?: number - legendX?: number - legendAlign?: HorizontalAlign - - legendWidth?: number - legendOpacity?: number - legendMaxWidth?: number - - onLegendMouseLeave?: () => void - onLegendMouseOver?: (d: ColorScaleBin) => void -} - -export interface HorizontalCategoricalColorLegendProps - extends HorizontalColorLegendProps { - categoricalLegendData: CategoricalBin[] - categoricalBinStroke?: Color - categoryLegendY?: number - - focusColors?: string[] // focused colors are bolded - hoverColors?: string[] // non-hovered colors are muted - activeColors?: string[] // inactive colors are grayed out - - onLegendClick?: (d: ColorScaleBin) => void - isStatic?: boolean -} - -export interface HorizontalNumericColorLegendProps - extends HorizontalColorLegendProps { - numericLegendData: ColorScaleBin[] - numericBinSize?: number - numericBinStroke?: Color - numericBinStrokeWidth?: number - equalSizeBins?: boolean - legendTitle?: string - numericFocusBracket?: ColorScaleBin - numericLegendY?: number - legendTextColor?: Color - legendTickSize?: number -} - -const DEFAULT_NUMERIC_BIN_SIZE = 10 -const DEFAULT_NUMERIC_BIN_STROKE = "#333" -const DEFAULT_NUMERIC_BIN_STROKE_WIDTH = 0.3 -const DEFAULT_TEXT_COLOR = "#111" -const DEFAULT_TICK_SIZE = 3 - -const CATEGORICAL_BIN_MIN_WIDTH = 20 -const FOCUS_BORDER_COLOR = "#111" -const SPACE_BETWEEN_CATEGORICAL_BINS = 7 -const MINIMUM_LABEL_DISTANCE = 5 - -export abstract class HorizontalColorLegend< - Props extends HorizontalColorLegendProps = HorizontalColorLegendProps, -> extends React.Component { - @computed protected get legendX(): number { - return this.props.legendX ?? 0 - } - - @computed protected get legendAlign(): HorizontalAlign { - // Assume center alignment if none specified, for backwards-compatibility - return this.props.legendAlign ?? HorizontalAlign.center - } - - @computed protected get fontSize(): number { - return this.props.fontSize ?? BASE_FONT_SIZE - } - - @computed protected get legendMaxWidth(): number | undefined { - return this.props.legendMaxWidth - } - - abstract get height(): number - abstract get width(): number -} - -@observer -export class HorizontalNumericColorLegend extends HorizontalColorLegend { - base: React.RefObject = React.createRef() - - static height( - props: Pick< - HorizontalNumericColorLegendProps, - | "numericLegendData" - | "numericBinSize" - | "fontSize" - | "legendWidth" - | "legendMaxWidth" - | "legendTitle" - | "equalSizeBins" - | "legendAlign" - | "legendX" - > - ): number { - const legend = new HorizontalNumericColorLegend(props) - return legend.height - } - - static width( - props: Pick< - HorizontalNumericColorLegendProps, - | "numericLegendData" - | "numericBinSize" - | "fontSize" - | "legendWidth" - | "legendMaxWidth" - | "legendTitle" - | "equalSizeBins" - > - ): number { - const legend = new HorizontalNumericColorLegend(props) - return legend.width - } - - @computed private get numericLegendY(): number { - return this.props.numericLegendY ?? 0 - } - - @computed private get legendTextColor(): Color { - return this.props.legendTextColor ?? DEFAULT_TEXT_COLOR - } - - @computed private get legendTickSize(): number { - return this.props.legendTickSize ?? DEFAULT_TICK_SIZE - } - - @computed private get numericLegendData(): ColorScaleBin[] { - return this.props.numericLegendData ?? [] - } - - @computed private get visibleBins(): ColorScaleBin[] { - return this.numericLegendData.filter((bin) => !bin.isHidden) - } - - @computed private get numericBins(): NumericBin[] { - return this.visibleBins.filter( - (bin): bin is NumericBin => bin instanceof NumericBin - ) - } - - @computed private get numericBinSize(): number { - return this.props.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE - } - - @computed private get numericBinStroke(): Color { - return this.props.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE - } - - @computed private get numericBinStrokeWidth(): number { - return ( - this.props.numericBinStrokeWidth ?? DEFAULT_NUMERIC_BIN_STROKE_WIDTH - ) - } - - @computed private get tickFontSize(): number { - return GRAPHER_FONT_SCALE_12 * this.fontSize - } - - @computed private get itemMargin(): number { - return Math.round(this.fontSize * 1.125) - } - - // NumericColorLegend wants to map a range to a width. However, sometimes we are given - // data without a clear min/max. So we must fit these scurrilous bins into the width somehow. - @computed private get minValue(): number { - return min(this.numericBins.map((bin) => bin.min)) as number - } - @computed private get maxValue(): number { - return max(this.numericBins.map((bin) => bin.max)) as number - } - @computed private get rangeSize(): number { - return this.maxValue - this.minValue - } - - @computed private get maxWidth(): number { - return this.props.legendMaxWidth ?? this.props.legendWidth ?? 200 - } - - private getTickLabelWidth(label: string): number { - return Bounds.forText(label, { - fontSize: this.tickFontSize, - }).width - } - - private getCategoricalBinWidth(bin: ColorScaleBin): number { - return Math.max( - this.getTickLabelWidth(bin.text), - CATEGORICAL_BIN_MIN_WIDTH - ) - } - - @computed private get totalCategoricalWidth(): number { - const { visibleBins, itemMargin } = this - const widths = visibleBins.map((bin) => - bin instanceof CategoricalBin && !bin.isHidden - ? this.getCategoricalBinWidth(bin) + itemMargin - : 0 - ) - return sum(widths) - } - - @computed private get isAutoWidth(): boolean { - return ( - this.props.legendWidth === undefined && - this.props.legendMaxWidth !== undefined - ) - } - - private getNumericLabelMinWidth(bin: NumericBin): number { - if (bin.text) { - const tickLabelWidth = this.getTickLabelWidth(bin.text) - return tickLabelWidth + MINIMUM_LABEL_DISTANCE - } else { - const combinedLabelWidths = sum( - [bin.minText, bin.maxText].map( - (text) => - // because labels are center-aligned, only half the label space is required - this.getTickLabelWidth(text) / 2 - ) - ) - return combinedLabelWidths + MINIMUM_LABEL_DISTANCE * 2 - } - } - - // Overstretched legends don't look good. - // If the manager provides `legendMaxWidth`, then we calculate an _ideal_ width for the legend. - @computed private get idealNumericWidth(): number { - const binCount = this.numericBins.length - const spaceRequirements = this.numericBins.map((bin) => ({ - labelSpace: this.getNumericLabelMinWidth(bin), - shareOfTotal: (bin.max - bin.min) / this.rangeSize, - })) - // Make sure the legend is big enough to avoid overlapping labels (including `raisedMode`) - if (this.props.equalSizeBins) { - // Try to keep the minimum close to the size of the "No data" bin, - // so they look visually balanced somewhat. - const minBinWidth = this.fontSize * 3.25 - const maxBinWidth = - max( - spaceRequirements.map(({ labelSpace }) => - Math.max(labelSpace, minBinWidth) - ) - ) ?? 0 - return Math.round(maxBinWidth * binCount) - } else { - const minBinWidth = this.fontSize * 2 - const maxTotalWidth = - max( - spaceRequirements.map(({ labelSpace, shareOfTotal }) => - Math.max(labelSpace / shareOfTotal, minBinWidth) - ) - ) ?? 0 - return Math.round(maxTotalWidth) - } - } - - @computed get width(): number { - if (this.isAutoWidth) { - return Math.min( - this.maxWidth, - this.legendTitleWidth + - this.totalCategoricalWidth + - this.idealNumericWidth - ) - } else { - return this.maxWidth - } - } - - @computed private get availableNumericWidth(): number { - return this.width - this.totalCategoricalWidth - this.legendTitleWidth - } - - // Since we calculate the width automatically in some cases (when `isAutoWidth` is true), - // we need to shift X to align the legend horizontally (`legendAlign`). - @computed private get x(): number { - const { width, maxWidth, legendAlign, legendX } = this - const widthDiff = maxWidth - width - if (legendAlign === HorizontalAlign.center) { - return legendX + widthDiff / 2 - } else if (legendAlign === HorizontalAlign.right) { - return legendX + widthDiff - } else { - return legendX // left align - } - } - - @computed private get positionedBins(): PositionedBin[] { - const { - rangeSize, - availableNumericWidth, - visibleBins, - numericBins, - legendTitleWidth, - x, - } = this - const { equalSizeBins } = this.props - - let xOffset = x + legendTitleWidth - let prevBin: ColorScaleBin | undefined - - return visibleBins.map((bin, index) => { - const isFirst = index === 0 - let width: number = this.getCategoricalBinWidth(bin) - let marginLeft: number = isFirst ? 0 : this.itemMargin - - if (bin instanceof NumericBin) { - if (equalSizeBins) { - width = availableNumericWidth / numericBins.length - } else { - width = - ((bin.max - bin.min) / rangeSize) * - availableNumericWidth - } - // Don't add any margin between numeric bins - if (prevBin instanceof NumericBin) { - marginLeft = 0 - } - } - - const x = xOffset + marginLeft - xOffset = x + width - prevBin = bin - - return { - x, - width, - bin, - } - }) - } - - @computed private get legendTitleFontSize(): number { - return this.fontSize * GRAPHER_FONT_SCALE_14 - } - - @computed private get legendTitle(): TextWrap | undefined { - const { legendTitle } = this.props - return legendTitle - ? new TextWrap({ - text: legendTitle, - fontSize: this.legendTitleFontSize, - fontWeight: 700, - maxWidth: this.maxWidth / 3, - lineHeight: 1, - }) - : undefined - } - - @computed private get legendTitleWidth(): number { - return this.legendTitle ? this.legendTitle.width + this.itemMargin : 0 - } - - @computed private get numericLabels(): NumericLabel[] { - const { numericBinSize, positionedBins, tickFontSize } = this - - const makeBoundaryLabel = ( - bin: PositionedBin, - minOrMax: "min" | "max", - text: string - ): NumericLabel => { - const labelBounds = Bounds.forText(text, { fontSize: tickFontSize }) - const x = - bin.x + - (minOrMax === "min" ? 0 : bin.width) - - labelBounds.width / 2 - const y = -numericBinSize - labelBounds.height - this.legendTickSize - - return { - text: text, - fontSize: tickFontSize, - bounds: labelBounds.set({ x: x, y: y }), - hidden: false, - raised: false, - } - } - - const makeRangeLabel = (bin: PositionedBin): NumericLabel => { - const labelBounds = Bounds.forText(bin.bin.text, { - fontSize: tickFontSize, - }) - const x = bin.x + bin.width / 2 - labelBounds.width / 2 - const y = -numericBinSize - labelBounds.height - this.legendTickSize - - return { - text: bin.bin.text, - fontSize: tickFontSize, - bounds: labelBounds.set({ x: x, y: y }), - priority: true, - hidden: false, - raised: false, - } - } - - let labels: NumericLabel[] = [] - for (const bin of positionedBins) { - if (bin.bin.text) labels.push(makeRangeLabel(bin)) - else if (bin.bin instanceof NumericBin) { - if (bin.bin.minText) - labels.push(makeBoundaryLabel(bin, "min", bin.bin.minText)) - if (bin === last(positionedBins) && bin.bin.maxText) - labels.push(makeBoundaryLabel(bin, "max", bin.bin.maxText)) - } - } - - for (let index = 0; index < labels.length; index++) { - const l1 = labels[index] - if (l1.hidden) continue - - for (let j = index + 1; j < labels.length; j++) { - const l2 = labels[j] - if ( - l1.bounds.right + MINIMUM_LABEL_DISTANCE > - l2.bounds.centerX || - (l2.bounds.left - MINIMUM_LABEL_DISTANCE < - l1.bounds.centerX && - !l2.priority) - ) - l2.hidden = true - } - } - - labels = labels.filter((label) => !label.hidden) - - // If labels overlap, first we try alternating raised labels - let raisedMode = false - for (let index = 1; index < labels.length; index++) { - const l1 = labels[index - 1], - l2 = labels[index] - if (l1.bounds.right + MINIMUM_LABEL_DISTANCE > l2.bounds.left) { - raisedMode = true - break - } - } - - if (raisedMode) { - for (let index = 1; index < labels.length; index++) { - const label = labels[index] - if (index % 2 !== 0) { - label.bounds = label.bounds.set({ - y: label.bounds.y - label.bounds.height - 1, - }) - label.raised = true - } - } - } - - return labels - } - - @computed get height(): number { - return Math.abs( - min(this.numericLabels.map((label) => label.bounds.y)) ?? 0 - ) - } - - @computed private get bounds(): Bounds { - return new Bounds(this.x, this.numericLegendY, this.width, this.height) - } - - @action.bound private onMouseMove(ev: MouseEvent | TouchEvent): void { - const { base, positionedBins } = this - const { numericFocusBracket, onLegendMouseLeave, onLegendMouseOver } = - this.props - if (base.current) { - const mouse = getRelativeMouse(base.current, ev) - - // We implement onMouseMove and onMouseLeave in a custom way, without attaching them to - // specific SVG elements, in order to allow continuous transition between bins as the user - // moves their cursor across (even if their cursor is in the empty area above the - // legend, where the labels are). - // We could achieve the same by rendering invisible rectangles over the areas and attaching - // event handlers to those. - - // If outside legend bounds, trigger onMouseLeave if there is an existing bin in focus. - if (!this.bounds.contains(mouse)) { - if (numericFocusBracket && onLegendMouseLeave) - return onLegendMouseLeave() - return - } - - // If inside legend bounds, trigger onMouseOver with the bin closest to the cursor. - let newFocusBracket: ColorScaleBin | undefined - positionedBins.forEach((bin) => { - if (mouse.x >= bin.x && mouse.x <= bin.x + bin.width) - newFocusBracket = bin.bin - }) - - if (newFocusBracket && onLegendMouseOver) - onLegendMouseOver(newFocusBracket) - } - } - - componentDidMount(): void { - document.documentElement.addEventListener("mousemove", this.onMouseMove) - document.documentElement.addEventListener("touchmove", this.onMouseMove) - } - - componentWillUnmount(): void { - document.documentElement.removeEventListener( - "mousemove", - this.onMouseMove - ) - document.documentElement.removeEventListener( - "touchmove", - this.onMouseMove - ) - } - - render(): React.ReactElement { - const { numericLabels, numericBinSize, positionedBins, height } = this - const { numericFocusBracket, legendOpacity } = this.props - - const stroke = this.numericBinStroke - const strokeWidth = this.numericBinStrokeWidth - const bottomY = this.numericLegendY + height - - return ( - - - {numericLabels.map((label, index) => ( - - ))} - - - {sortBy( - positionedBins.map((positionedBin, index) => { - const bin = positionedBin.bin - const isFocus = - numericFocusBracket && - bin.equals(numericFocusBracket) - return ( - - ) - }), - (rect) => rect.props["strokeWidth"] - )} - - - {numericLabels.map((label, index) => ( - - {label.text} - - ))} - - {this.legendTitle?.render( - this.x, - // Align legend title baseline with bottom of color bins - this.numericLegendY + - height - - this.legendTitle.height + - this.legendTitleFontSize * 0.2, - { textProps: { fill: this.legendTextColor } } - )} - - ) - } -} - -interface NumericBinRectProps extends React.SVGAttributes { - x: number - y: number - width: number - height: number - isOpenLeft?: boolean - isOpenRight?: boolean -} - -/** The width of the arrowhead for open-ended bins (left or right) */ -const ARROW_SIZE = 5 - -const NumericBinRect = (props: NumericBinRectProps) => { - const { isOpenLeft, isOpenRight, x, y, width, height, ...restProps } = props - if (isOpenRight) { - const a = ARROW_SIZE - const w = width - a - const d = removeAllWhitespace(` - M ${x}, ${y} - l ${w}, 0 - l ${a}, ${height / 2} - l ${-a}, ${height / 2} - l ${-w}, 0 - z - `) - return - } else if (isOpenLeft) { - const a = ARROW_SIZE - const w = width - a - const d = removeAllWhitespace(` - M ${x + a}, ${y} - l ${w}, 0 - l 0, ${height} - l ${-w}, 0 - l ${-a}, ${-height / 2} - z - `) - return - } else { - return - } -} - -@observer -export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { - private rectPadding = 5 - private markPadding = 5 - - static height( - props: Pick< - HorizontalCategoricalColorLegendProps, - | "categoricalLegendData" - | "fontSize" - | "legendWidth" - | "legendMaxWidth" - | "legendAlign" - > - ): number { - const legend = new HorizontalCategoricalColorLegend(props) - return legend.height - } - - static numLines( - props: Pick< - HorizontalCategoricalColorLegendProps, - | "categoricalLegendData" - | "fontSize" - | "legendWidth" - | "legendMaxWidth" - > - ): number { - const legend = new HorizontalCategoricalColorLegend(props) - return legend.numLines - } - - @computed private get categoryLegendY(): number { - return this.props.categoryLegendY ?? 0 - } - - @computed get width(): number { - return this.props.legendWidth ?? this.legendMaxWidth ?? 200 - } - - @computed private get categoricalLegendData(): CategoricalBin[] { - return this.props.categoricalLegendData ?? [] - } - - @computed private get visibleCategoricalBins(): CategoricalBin[] { - return this.categoricalLegendData.filter((bin) => !bin.isHidden) - } - - @computed private get markLines(): MarkLine[] { - const fontSize = this.fontSize * GRAPHER_FONT_SCALE_12_8 - const rectSize = this.fontSize * 0.75 - - const lines: MarkLine[] = [] - let marks: CategoricalMark[] = [] - let xOffset = 0 - let yOffset = 0 - this.visibleCategoricalBins.forEach((bin) => { - const labelBounds = Bounds.forText(bin.text, { fontSize }) - const markWidth = - rectSize + - this.rectPadding + - labelBounds.width + - this.markPadding - - if (xOffset + markWidth > this.width && marks.length > 0) { - lines.push({ - totalWidth: xOffset - this.markPadding, - marks: marks, - }) - marks = [] - xOffset = 0 - yOffset += rectSize + this.rectPadding - } - - const markX = xOffset - const markY = yOffset - - const label = { - text: bin.text, - bounds: labelBounds.set({ - x: markX + rectSize + this.rectPadding, - y: markY + rectSize / 2, - }), - fontSize, - } - - marks.push({ - x: markX, - y: markY, - width: markWidth, - rectSize, - label, - bin, - }) - - xOffset += markWidth + SPACE_BETWEEN_CATEGORICAL_BINS - }) - - if (marks.length > 0) - lines.push({ totalWidth: xOffset - this.markPadding, marks: marks }) - - return lines - } - - @computed private get contentWidth(): number { - return max(this.markLines.map((l) => l.totalWidth)) as number - } - - @computed private get containerWidth(): number { - return this.width ?? this.contentWidth - } - - @computed private get marks(): CategoricalMark[] { - const lines = this.markLines - const align = this.legendAlign - const width = this.containerWidth - - // Center each line - lines.forEach((line) => { - // TODO abstract this - const xShift = - align === HorizontalAlign.center - ? (width - line.totalWidth) / 2 - : align === HorizontalAlign.right - ? width - line.totalWidth - : 0 - line.marks.forEach((mark) => { - mark.x += xShift - mark.label.bounds = mark.label.bounds.set({ - x: mark.label.bounds.x + xShift, - }) - }) - }) - - return lines.flatMap((l) => l.marks) - } - - @computed get height(): number { - return max(this.marks.map((mark) => mark.y + mark.rectSize)) ?? 0 - } - - @computed get numLines(): number { - return this.markLines.length - } - - renderLabels(): React.ReactElement { - const { marks } = this - const { focusColors, hoverColors = [] } = this.props - - return ( - - {marks.map((mark, index) => { - const isFocus = focusColors?.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - return ( - - {mark.label.text} - - ) - })} - - ) - } - - renderSwatches(): React.ReactElement { - const { marks } = this - const { - activeColors, - hoverColors = [], - legendOpacity, - categoricalBinStroke, - } = this.props - - return ( - - {marks.map((mark, index) => { - const isActive = activeColors?.includes(mark.bin.color) - const isHovered = hoverColors.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - const color = mark.bin.patternRef - ? `url(#${mark.bin.patternRef})` - : mark.bin.color - - const fill = - isHovered || isActive || activeColors === undefined - ? color - : OWID_NON_FOCUSED_GRAY - - const opacity = isNotHovered - ? GRAPHER_OPACITY_MUTE - : legendOpacity - - return ( - - ) - })} - - ) - } - - renderInteractiveElements(): React.ReactElement { - const { marks, props } = this - - return ( - - {marks.map((mark, index) => { - const mouseOver = (): void => - props.onLegendMouseOver - ? props.onLegendMouseOver(mark.bin) - : undefined - const mouseLeave = (): void => - props.onLegendMouseLeave - ? props.onLegendMouseLeave() - : undefined - const click = props.onLegendClick - ? (): void => props.onLegendClick?.(mark.bin) - : undefined - - const cursor = click ? "pointer" : "default" - - return ( - - {/* for hover interaction */} - - - ) - })} - - ) - } - - render(): React.ReactElement { - return ( - - {this.renderSwatches()} - {this.renderLabels()} - {!this.props.isStatic && this.renderInteractiveElements()} - - ) - } -} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts new file mode 100644 index 00000000000..b257f4b6acf --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts @@ -0,0 +1,409 @@ +import { Color, HorizontalAlign } from "@ourworldindata/types" +import { + CategoricalBin, + ColorScaleBin, + NumericBin, +} from "../color/ColorScaleBin" +import { + AbstractHorizontalColorLegend, + HorizontalColorLegendProps, +} from "./AbstractHorizontalColorLegend" +import { computed } from "mobx" +import { + CATEGORICAL_BIN_MIN_WIDTH, + DEFAULT_NUMERIC_BIN_SIZE, + DEFAULT_NUMERIC_BIN_STROKE, + DEFAULT_NUMERIC_BIN_STROKE_WIDTH, + DEFAULT_TEXT_COLOR, + DEFAULT_TICK_SIZE, + MINIMUM_LABEL_DISTANCE, +} from "./HorizontalColorLegendConstants" +import { + GRAPHER_FONT_SCALE_12, + GRAPHER_FONT_SCALE_14, +} from "../core/GrapherConstants" +import { Bounds, last, max, min, sum } from "@ourworldindata/utils" +import { TextWrap } from "@ourworldindata/components" + +export interface HorizontalNumericColorLegendProps + extends HorizontalColorLegendProps { + numericLegendData: ColorScaleBin[] + numericBinSize?: number + numericBinStroke?: Color + numericBinStrokeWidth?: number + equalSizeBins?: boolean + legendWidth?: number + legendTitle?: string + numericFocusBracket?: ColorScaleBin + numericLegendY?: number + legendTextColor?: Color + legendTickSize?: number +} + +interface NumericLabel { + text: string + fontSize: number + bounds: Bounds + priority?: boolean + hidden: boolean + raised: boolean +} + +export interface PositionedBin { + x: number + width: number + bin: ColorScaleBin +} + +export class HorizontalNumericColorLegend extends AbstractHorizontalColorLegend { + static height(props: HorizontalNumericColorLegendProps): number { + const legend = new HorizontalNumericColorLegend(props) + return legend.height + } + + @computed get numericLegendY(): number { + return this.props.numericLegendY ?? 0 + } + + @computed get legendTextColor(): Color { + return this.props.legendTextColor ?? DEFAULT_TEXT_COLOR + } + + @computed private get legendTickSize(): number { + return this.props.legendTickSize ?? DEFAULT_TICK_SIZE + } + + @computed private get numericLegendData(): ColorScaleBin[] { + return this.props.numericLegendData ?? [] + } + + @computed private get visibleBins(): ColorScaleBin[] { + return this.numericLegendData.filter((bin) => !bin.isHidden) + } + + @computed private get numericBins(): NumericBin[] { + return this.visibleBins.filter( + (bin): bin is NumericBin => bin instanceof NumericBin + ) + } + + @computed get numericBinSize(): number { + return this.props.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE + } + + @computed get numericBinStroke(): Color { + return this.props.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE + } + + @computed get numericBinStrokeWidth(): number { + return ( + this.props.numericBinStrokeWidth ?? DEFAULT_NUMERIC_BIN_STROKE_WIDTH + ) + } + + @computed private get tickFontSize(): number { + return GRAPHER_FONT_SCALE_12 * this.fontSize + } + + @computed private get itemMargin(): number { + return Math.round(this.fontSize * 1.125) + } + + // NumericColorLegend wants to map a range to a width. However, sometimes we are given + // data without a clear min/max. So we must fit these scurrilous bins into the width somehow. + @computed private get minValue(): number { + return min(this.numericBins.map((bin) => bin.min)) as number + } + @computed private get maxValue(): number { + return max(this.numericBins.map((bin) => bin.max)) as number + } + @computed private get rangeSize(): number { + return this.maxValue - this.minValue + } + + @computed private get maxWidth(): number { + return this.props.legendMaxWidth ?? this.props.legendWidth ?? 200 + } + + private getTickLabelWidth(label: string): number { + return Bounds.forText(label, { + fontSize: this.tickFontSize, + }).width + } + + private getCategoricalBinWidth(bin: ColorScaleBin): number { + return Math.max( + this.getTickLabelWidth(bin.text), + CATEGORICAL_BIN_MIN_WIDTH + ) + } + + @computed private get totalCategoricalWidth(): number { + const { visibleBins, itemMargin } = this + const widths = visibleBins.map((bin) => + bin instanceof CategoricalBin && !bin.isHidden + ? this.getCategoricalBinWidth(bin) + itemMargin + : 0 + ) + return sum(widths) + } + + @computed private get isAutoWidth(): boolean { + return ( + this.props.legendWidth === undefined && + this.props.legendMaxWidth !== undefined + ) + } + + private getNumericLabelMinWidth(bin: NumericBin): number { + if (bin.text) { + const tickLabelWidth = this.getTickLabelWidth(bin.text) + return tickLabelWidth + MINIMUM_LABEL_DISTANCE + } else { + const combinedLabelWidths = sum( + [bin.minText, bin.maxText].map( + (text) => + // because labels are center-aligned, only half the label space is required + this.getTickLabelWidth(text) / 2 + ) + ) + return combinedLabelWidths + MINIMUM_LABEL_DISTANCE * 2 + } + } + + // Overstretched legends don't look good. + // If the manager provides `legendMaxWidth`, then we calculate an _ideal_ width for the legend. + @computed private get idealNumericWidth(): number { + const binCount = this.numericBins.length + const spaceRequirements = this.numericBins.map((bin) => ({ + labelSpace: this.getNumericLabelMinWidth(bin), + shareOfTotal: (bin.max - bin.min) / this.rangeSize, + })) + // Make sure the legend is big enough to avoid overlapping labels (including `raisedMode`) + if (this.props.equalSizeBins) { + // Try to keep the minimum close to the size of the "No data" bin, + // so they look visually balanced somewhat. + const minBinWidth = this.fontSize * 3.25 + const maxBinWidth = + max( + spaceRequirements.map(({ labelSpace }) => + Math.max(labelSpace, minBinWidth) + ) + ) ?? 0 + return Math.round(maxBinWidth * binCount) + } else { + const minBinWidth = this.fontSize * 2 + const maxTotalWidth = + max( + spaceRequirements.map(({ labelSpace, shareOfTotal }) => + Math.max(labelSpace / shareOfTotal, minBinWidth) + ) + ) ?? 0 + return Math.round(maxTotalWidth) + } + } + + @computed get width(): number { + if (this.isAutoWidth) { + return Math.min( + this.maxWidth, + this.legendTitleWidth + + this.totalCategoricalWidth + + this.idealNumericWidth + ) + } else { + return this.maxWidth + } + } + + @computed private get availableNumericWidth(): number { + return this.width - this.totalCategoricalWidth - this.legendTitleWidth + } + + // Since we calculate the width automatically in some cases (when `isAutoWidth` is true), + // we need to shift X to align the legend horizontally (`legendAlign`). + @computed get x(): number { + const { width, maxWidth, legendAlign, legendX } = this + const widthDiff = maxWidth - width + if (legendAlign === HorizontalAlign.center) { + return legendX + widthDiff / 2 + } else if (legendAlign === HorizontalAlign.right) { + return legendX + widthDiff + } else { + return legendX // left align + } + } + + @computed get positionedBins(): PositionedBin[] { + const { + rangeSize, + availableNumericWidth, + visibleBins, + numericBins, + legendTitleWidth, + x, + } = this + const { equalSizeBins } = this.props + + let xOffset = x + legendTitleWidth + let prevBin: ColorScaleBin | undefined + + return visibleBins.map((bin, index) => { + const isFirst = index === 0 + let width: number = this.getCategoricalBinWidth(bin) + let marginLeft: number = isFirst ? 0 : this.itemMargin + + if (bin instanceof NumericBin) { + if (equalSizeBins) { + width = availableNumericWidth / numericBins.length + } else { + width = + ((bin.max - bin.min) / rangeSize) * + availableNumericWidth + } + // Don't add any margin between numeric bins + if (prevBin instanceof NumericBin) { + marginLeft = 0 + } + } + + const x = xOffset + marginLeft + xOffset = x + width + prevBin = bin + + return { + x, + width, + bin, + } + }) + } + + @computed get legendTitleFontSize(): number { + return this.fontSize * GRAPHER_FONT_SCALE_14 + } + + @computed get legendTitle(): TextWrap | undefined { + const { legendTitle } = this.props + return legendTitle + ? new TextWrap({ + text: legendTitle, + fontSize: this.legendTitleFontSize, + fontWeight: 700, + maxWidth: this.maxWidth / 3, + lineHeight: 1, + }) + : undefined + } + + @computed private get legendTitleWidth(): number { + return this.legendTitle ? this.legendTitle.width + this.itemMargin : 0 + } + + @computed get numericLabels(): NumericLabel[] { + const { numericBinSize, positionedBins, tickFontSize } = this + + const makeBoundaryLabel = ( + bin: PositionedBin, + minOrMax: "min" | "max", + text: string + ): NumericLabel => { + const labelBounds = Bounds.forText(text, { fontSize: tickFontSize }) + const x = + bin.x + + (minOrMax === "min" ? 0 : bin.width) - + labelBounds.width / 2 + const y = -numericBinSize - labelBounds.height - this.legendTickSize + + return { + text: text, + fontSize: tickFontSize, + bounds: labelBounds.set({ x: x, y: y }), + hidden: false, + raised: false, + } + } + + const makeRangeLabel = (bin: PositionedBin): NumericLabel => { + const labelBounds = Bounds.forText(bin.bin.text, { + fontSize: tickFontSize, + }) + const x = bin.x + bin.width / 2 - labelBounds.width / 2 + const y = -numericBinSize - labelBounds.height - this.legendTickSize + + return { + text: bin.bin.text, + fontSize: tickFontSize, + bounds: labelBounds.set({ x: x, y: y }), + priority: true, + hidden: false, + raised: false, + } + } + + let labels: NumericLabel[] = [] + for (const bin of positionedBins) { + if (bin.bin.text) labels.push(makeRangeLabel(bin)) + else if (bin.bin instanceof NumericBin) { + if (bin.bin.minText) + labels.push(makeBoundaryLabel(bin, "min", bin.bin.minText)) + if (bin === last(positionedBins) && bin.bin.maxText) + labels.push(makeBoundaryLabel(bin, "max", bin.bin.maxText)) + } + } + + for (let index = 0; index < labels.length; index++) { + const l1 = labels[index] + if (l1.hidden) continue + + for (let j = index + 1; j < labels.length; j++) { + const l2 = labels[j] + if ( + l1.bounds.right + MINIMUM_LABEL_DISTANCE > + l2.bounds.centerX || + (l2.bounds.left - MINIMUM_LABEL_DISTANCE < + l1.bounds.centerX && + !l2.priority) + ) + l2.hidden = true + } + } + + labels = labels.filter((label) => !label.hidden) + + // If labels overlap, first we try alternating raised labels + let raisedMode = false + for (let index = 1; index < labels.length; index++) { + const l1 = labels[index - 1], + l2 = labels[index] + if (l1.bounds.right + MINIMUM_LABEL_DISTANCE > l2.bounds.left) { + raisedMode = true + break + } + } + + if (raisedMode) { + for (let index = 1; index < labels.length; index++) { + const label = labels[index] + if (index % 2 !== 0) { + label.bounds = label.bounds.set({ + y: label.bounds.y - label.bounds.height - 1, + }) + label.raised = true + } + } + } + + return labels + } + + @computed get height(): number { + return Math.abs( + min(this.numericLabels.map((label) => label.bounds.y)) ?? 0 + ) + } + + @computed get bounds(): Bounds { + return new Bounds(this.x, this.numericLegendY, this.width, this.height) + } +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx new file mode 100644 index 00000000000..0005c1825fd --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx @@ -0,0 +1,229 @@ +import React from "react" +import { HorizontalNumericColorLegend } from "./HorizontalNumericColorLegend" +import { action, computed } from "mobx" +import { + dyFromAlign, + getRelativeMouse, + makeIdForHumanConsumption, + removeAllWhitespace, + sortBy, + VerticalAlign, +} from "@ourworldindata/utils" +import { ColorScaleBin, NumericBin } from "../color/ColorScaleBin" +import { darkenColorForLine } from "../color/ColorUtils" +import { observer } from "mobx-react" + +const FOCUS_BORDER_COLOR = "#111" + +@observer +export class HorizontalNumericColorLegendComponent extends React.Component<{ + legend: HorizontalNumericColorLegend + legendOpacity?: number + onLegendMouseLeave?: () => void + onLegendMouseOver?: (d: ColorScaleBin) => void +}> { + base: React.RefObject = React.createRef() + + @computed private get legend(): HorizontalNumericColorLegend { + return this.props.legend + } + + @action.bound private onMouseMove(ev: MouseEvent | TouchEvent): void { + const { base } = this + const { positionedBins } = this.legend + const { numericFocusBracket } = this.props.legend.props // TODO + const { onLegendMouseLeave, onLegendMouseOver } = this.props + if (base.current) { + const mouse = getRelativeMouse(base.current, ev) + + // We implement onMouseMove and onMouseLeave in a custom way, without attaching them to + // specific SVG elements, in order to allow continuous transition between bins as the user + // moves their cursor across (even if their cursor is in the empty area above the + // legend, where the labels are). + // We could achieve the same by rendering invisible rectangles over the areas and attaching + // event handlers to those. + + // If outside legend bounds, trigger onMouseLeave if there is an existing bin in focus. + if (!this.legend.bounds.contains(mouse)) { + if (numericFocusBracket && onLegendMouseLeave) + return onLegendMouseLeave() + return + } + + // If inside legend bounds, trigger onMouseOver with the bin closest to the cursor. + let newFocusBracket: ColorScaleBin | undefined + positionedBins.forEach((bin) => { + if (mouse.x >= bin.x && mouse.x <= bin.x + bin.width) + newFocusBracket = bin.bin + }) + + if (newFocusBracket && onLegendMouseOver) + onLegendMouseOver(newFocusBracket) + } + } + + componentDidMount(): void { + document.documentElement.addEventListener("mousemove", this.onMouseMove) + document.documentElement.addEventListener("touchmove", this.onMouseMove) + } + + componentWillUnmount(): void { + document.documentElement.removeEventListener( + "mousemove", + this.onMouseMove + ) + document.documentElement.removeEventListener( + "touchmove", + this.onMouseMove + ) + } + + render(): React.ReactElement { + const { numericLabels, numericBinSize, positionedBins, height } = + this.legend + const { numericFocusBracket } = this.legend.props + const { legendOpacity } = this.props + + const stroke = this.legend.numericBinStroke + const strokeWidth = this.legend.numericBinStrokeWidth + const bottomY = this.legend.numericLegendY + height + + return ( + + + {numericLabels.map((label, index) => ( + + ))} + + + {sortBy( + positionedBins.map((positionedBin, index) => { + const bin = positionedBin.bin + const isFocus = + numericFocusBracket && + bin.equals(numericFocusBracket) + return ( + + ) + }), + (rect) => rect.props["strokeWidth"] + )} + + + {numericLabels.map((label, index) => ( + + {label.text} + + ))} + + {this.legend.legendTitle?.render( + this.legend.x, + // Align legend title baseline with bottom of color bins + this.legend.numericLegendY + + height - + this.legend.legendTitle.height + + this.legend.legendTitleFontSize * 0.2, + { textProps: { fill: this.legend.legendTextColor } } + )} + + ) + } +} + +interface NumericBinRectProps extends React.SVGAttributes { + x: number + y: number + width: number + height: number + isOpenLeft?: boolean + isOpenRight?: boolean +} + +/** The width of the arrowhead for open-ended bins (left or right) */ +const ARROW_SIZE = 5 + +const NumericBinRect = (props: NumericBinRectProps) => { + const { isOpenLeft, isOpenRight, x, y, width, height, ...restProps } = props + if (isOpenRight) { + const a = ARROW_SIZE + const w = width - a + const d = removeAllWhitespace(` + M ${x}, ${y} + l ${w}, 0 + l ${a}, ${height / 2} + l ${-a}, ${height / 2} + l ${-w}, 0 + z + `) + return + } else if (isOpenLeft) { + const a = ARROW_SIZE + const w = width - a + const d = removeAllWhitespace(` + M ${x + a}, ${y} + l ${w}, 0 + l 0, ${height} + l ${-w}, 0 + l ${-a}, ${-height / 2} + z + `) + return + } else { + return + } +} diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 04d4e9202a5..70cff50ca5c 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -103,11 +103,6 @@ import { import { MultiColorPolyline } from "../scatterCharts/MultiColorPolyline" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" import { darkenColorForLine } from "../color/ColorUtils" -import { - HorizontalCategoricalColorLegendProps, - HorizontalNumericColorLegend, - HorizontalNumericColorLegendProps, -} from "../horizontalColorLegend/HorizontalColorLegends" import { AnnotationsMap, getAnnotationsForSeries, @@ -116,6 +111,12 @@ import { getSeriesName, } from "./LineChartHelpers" import { FocusArray } from "../focus/FocusArray.js" +import { + HorizontalNumericColorLegend, + HorizontalNumericColorLegendProps, +} from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const LINE_CHART_CLASS_NAME = "LineChart" @@ -928,7 +929,11 @@ export class LineChart renderColorLegend(): React.ReactElement | void { if (this.hasColorLegend) - return + return ( + + ) } /** @@ -1154,13 +1159,17 @@ export class LineChart return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } + @computed get colorLegend(): HorizontalNumericColorLegend { + return new HorizontalNumericColorLegend(this.colorLegendProps) + } + @computed get colorLegendHeight(): number { return this.hasColorScale && this.manager.showLegend ? HorizontalNumericColorLegend.height(this.colorLegendProps) : 0 } - get colorLegendProps(): HorizontalNumericColorLegendProps { + @computed get colorLegendProps(): HorizontalNumericColorLegendProps { return { fontSize: this.fontSize, legendX: this.legendX, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index bfbcc27d1e8..5a4263bf3e4 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -16,10 +16,8 @@ import { } from "@ourworldindata/utils" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" -import { - HorizontalCategoricalColorLegend, - HorizontalNumericColorLegend, -} from "../horizontalColorLegend/HorizontalColorLegends" +import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalNumericColorLegend } from "../horizontalColorLegend/HorizontalNumericColorLegend" import { MapProjectionGeos } from "./MapProjections" import { GeoPathRoundingContext } from "./GeoPathRoundingContext" import { select } from "d3-selection" @@ -70,6 +68,8 @@ import { import { NoDataModal } from "../noDataModal/NoDataModal" import { ColorScaleConfig } from "../color/ColorScaleConfig" import { SelectionArray } from "../selection/SelectionArray" +import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const DEFAULT_STROKE_COLOR = "#333" const CHOROPLETH_MAP_CLASSNAME = "ChoroplethMap" @@ -532,6 +532,39 @@ export class MapChart return this.numericLegendData.length > 1 } + @computed private get categoryLegend(): + | HorizontalCategoricalColorLegend + | undefined { + return this.hasCategoryLegend + ? new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + categoryLegendY: this.categoryLegendY, + legendMaxWidth: this.legendMaxWidth, + categoricalLegendData: this.categoricalLegendData, + categoricalBinStroke: this.categoricalBinStroke, + }) + : undefined + } + + @computed private get numericLegend(): + | HorizontalNumericColorLegend + | undefined { + return this.hasNumericLegend + ? new HorizontalNumericColorLegend({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + numericLegendY: this.numericLegendY, + legendMaxWidth: this.legendMaxWidth, + numericLegendData: this.numericLegendData, + numericFocusBracket: this.numericFocusBracket, + equalSizeBins: this.equalSizeBins, + }) + : undefined + } + @computed get categoryLegendHeight(): number { return this.hasCategoryLegend ? HorizontalCategoricalColorLegend.height({ @@ -609,36 +642,20 @@ export class MapChart } renderMapLegend(): React.ReactElement { - const { hasNumericLegend, hasCategoryLegend } = this - return ( <> - {hasNumericLegend && ( - )} - {hasCategoryLegend && ( - )} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index b3e044dd93d..2361ed36146 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -86,7 +86,6 @@ import { } from "../lineCharts/LineChartHelpers" import { SelectionArray } from "../selection/SelectionArray" import { Halo } from "@ourworldindata/components" -import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin } from "../color/ColorScaleBin" import { OWID_NON_FOCUSED_GRAY, @@ -94,6 +93,7 @@ import { GRAPHER_DARK_TEXT, } from "../color/ColorConstants" import { FocusArray } from "../focus/FocusArray" +import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" type SVGMouseOrTouchEvent = | React.MouseEvent diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index e6824d85d18..e9b25f10939 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -38,12 +38,12 @@ import { select } from "d3-selection" import { ColorSchemes } from "../color/ColorSchemes" import { SelectionArray } from "../selection/SelectionArray" import { CategoricalBin } from "../color/ColorScaleBin" -import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalColorAssigner, CategoricalColorMap, } from "../color/CategoricalColorAssigner.js" import { BinaryMapPaletteE } from "../color/CustomSchemes" +import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" // used in StackedBar charts to color negative and positive bars const POSITIVE_COLOR = BinaryMapPaletteE.colorSets[0][0] // orange diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index 7de3e4b740c..3bf1ed95b9f 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -58,7 +58,6 @@ import { makeTooltipRoundingNotice, makeTooltipToleranceNotice, } from "../tooltip/Tooltip" -import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" import { ColorScale, ColorScaleManager } from "../color/ColorScale" @@ -85,6 +84,8 @@ import { LabelCandidateWithElement, MarimekkoBarProps, } from "./MarimekkoChartConstants" +import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" const MARKER_MARGIN: number = 4 const MARKER_AREA_HEIGHT: number = 25 @@ -898,7 +899,18 @@ export class MarimekkoChart return HorizontalCategoricalColorLegend.height({ fontSize: this.fontSize, legendAlign: this.legendAlign, - legendWidth: this.legendWidth, + legendMaxWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) + } + + @computed get legend(): HorizontalCategoricalColorLegend { + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + categoryLegendY: this.categoryLegendY, + legendMaxWidth: this.legendWidth, categoricalLegendData: this.categoricalLegendData, }) } @@ -1055,17 +1067,11 @@ export class MarimekkoChart } detailsMarker={manager.detailsMarkerInSvg} /> - {this.renderBars()} {target && ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 5072d185e35..ce945928857 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -52,9 +52,10 @@ import { import { makeClipPath } from "../chart/ChartUtils" import { ColorScaleConfigDefaults } from "../color/ColorScaleConfig" import { ColumnTypeMap, CoreColumn } from "@ourworldindata/core-table" -import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { AxisConfig } from "../axis/AxisConfig.js" +import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" interface StackedBarSegmentProps extends React.SVGAttributes { id: string @@ -329,7 +330,18 @@ export class StackedBarChart return HorizontalCategoricalColorLegend.height({ fontSize: this.fontSize, legendAlign: this.legendAlign, - legendWidth: this.legendWidth, + legendMaxWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) + } + + @computed get colorLegend(): HorizontalCategoricalColorLegend { + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + categoryLegendY: this.categoryLegendY, + legendMaxWidth: this.legendWidth, categoricalLegendData: this.categoricalLegendData, }) } @@ -485,18 +497,15 @@ export class StackedBarChart : this.bounds.right - this.sidebarWidth const y = this.bounds.top + const onMouseOver = !isStatic ? this.onLegendMouseOver : undefined + const onMouseLeave = !isStatic ? this.onLegendMouseLeave : undefined + return showHorizontalLegend ? ( - ) : ( ) } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index 31b0434d7d5..a3736388524 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -66,10 +66,6 @@ import { } from "../tooltip/Tooltip" import { StackedPoint, StackedSeries } from "./StackedConstants" import { ColorSchemes } from "../color/ColorSchemes" -import { - HorizontalCategoricalColorLegend, - HorizontalCategoricalColorLegendProps, -} from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { isDarkColor } from "../color/ColorUtils" import { HorizontalAxis } from "../axis/Axis" @@ -80,6 +76,11 @@ import { easeQuadOut } from "d3-ease" import { bind } from "decko" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner.js" import { TextWrap } from "@ourworldindata/components" +import { + HorizontalCategoricalColorLegend, + HorizontalCategoricalColorLegendProps, +} from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" // if an entity name exceeds this width, we use the short name instead (if available) const SOFT_MAX_LABEL_WIDTH = 90 @@ -548,11 +549,22 @@ export class StackedDiscreteBarChart this.focusSeriesName = undefined } + @computed private get legend(): HorizontalCategoricalColorLegend { + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + legendX: this.legendX, + legendAlign: this.legendAlign, + categoryLegendY: this.categoryLegendY, + legendMaxWidth: this.legendWidth, + categoricalLegendData: this.categoricalLegendData, + }) + } + @computed private get legendHeight(): number { return HorizontalCategoricalColorLegend.height({ fontSize: this.fontSize, legendAlign: this.legendAlign, - legendWidth: this.legendWidth, + legendMaxWidth: this.legendWidth, categoricalLegendData: this.categoricalLegendData, }) } @@ -732,16 +744,10 @@ export class StackedDiscreteBarChart renderLegend(): React.ReactElement | void { if (!this.showLegend) return return ( - ) } From 1a701a94ce48205e0784d191f6a7de20d4c98bf9 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 01:05:31 +0100 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20external=20face?= =?UTF-8?q?t=20legend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/chart/ChartInterface.ts | 9 +-- .../grapher/src/facetChart/FacetChart.tsx | 81 ++++++++----------- .../grapher/src/lineCharts/LineChart.test.ts | 7 +- .../grapher/src/lineCharts/LineChart.tsx | 23 ++---- .../grapher/src/slopeCharts/SlopeChart.tsx | 2 +- .../stackedCharts/AbstractStackedChart.tsx | 2 +- .../stackedCharts/StackedAreaChart.test.ts | 6 +- .../StackedDiscreteBarChart.test.ts | 8 +- .../stackedCharts/StackedDiscreteBarChart.tsx | 2 +- 9 files changed, 53 insertions(+), 87 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts index 874113aea04..b22df8550e8 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts @@ -45,12 +45,9 @@ export interface ChartInterface { * The legend that has been hidden from the chart plot (using `manager.hideLegend`). * Used to create a global categorical legend for faceted charts. */ - externalCategoricalLegend?: HorizontalCategoricalColorLegendProps - /** - * The legend that has been hidden from the chart plot (using `manager.hideLegend`). - * Used to create a global numeric legend for faceted charts. - */ - externalNumericLegend?: HorizontalNumericColorLegendProps + externalLegend?: + | HorizontalCategoricalColorLegendProps + | HorizontalNumericColorLegendProps /** * Which facet strategies the chart type finds reasonable in its current setting, if any. diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 8e6ace6ca6c..d6a8e8e99b8 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -75,6 +75,9 @@ const SHARED_X_AXIS_MIN_FACET_COUNT = 12 const facetBackgroundColor = "none" // we don't use color yet but may use it for background later +type ExternalLegendProps = Partial & + Partial + const getContentBounds = ( containerBounds: Bounds, manager: ChartManager, @@ -595,26 +598,17 @@ export class FacetChart // legend utils @computed - private get externalCategoricalLegends(): HorizontalCategoricalColorLegendProps[] { - return excludeUndefined( - this.intermediateChartInstances.map( - (instance) => instance.externalCategoricalLegend - ) - ) - } - - @computed - private get externalNumericLegends(): HorizontalNumericColorLegendProps[] { + private get externalLegends(): ExternalLegendProps[] { return excludeUndefined( this.intermediateChartInstances.map( - (instance) => instance.externalNumericLegend + (instance) => instance.externalLegend ) ) } @computed private get isNumericLegend(): boolean { - return this.externalNumericLegends.some((legend) => - legend.numericLegendData.some((bin) => bin instanceof NumericBin) + return this.externalLegends.some((legend) => + legend.numericLegendData?.some((bin) => bin instanceof NumericBin) ) } @@ -648,21 +642,10 @@ export class FacetChart return false } - private getCategoricalExternalLegendProp< - Prop extends keyof HorizontalCategoricalColorLegendProps, - >(prop: Prop): HorizontalCategoricalColorLegendProps[Prop] | undefined { - for (const externalLegend of this.externalCategoricalLegends) { - if (externalLegend[prop] !== undefined) { - return externalLegend[prop] - } - } - return undefined - } - - private getNumericExternalLegendProp< - Prop extends keyof HorizontalNumericColorLegendProps, - >(prop: Prop): HorizontalNumericColorLegendProps[Prop] | undefined { - for (const externalLegend of this.externalNumericLegends) { + private getExternalLegendProp( + prop: Prop + ): ExternalLegendProps[Prop] | undefined { + for (const externalLegend of this.externalLegends) { if (externalLegend[prop] !== undefined) { return externalLegend[prop] } @@ -699,17 +682,15 @@ export class FacetChart return { ...this.commonLegendProps, numericLegendY: this.bounds.top, - legendTitle: this.getNumericExternalLegendProp("legendTitle"), - legendTextColor: - this.getNumericExternalLegendProp("legendTextColor"), - legendTickSize: this.getNumericExternalLegendProp("legendTickSize"), - numericBinSize: this.getNumericExternalLegendProp("numericBinSize"), - numericBinStroke: - this.getNumericExternalLegendProp("numericBinStroke"), - numericBinStrokeWidth: this.getNumericExternalLegendProp( + legendTitle: this.getExternalLegendProp("legendTitle"), + legendTextColor: this.getExternalLegendProp("legendTextColor"), + legendTickSize: this.getExternalLegendProp("legendTickSize"), + numericBinSize: this.getExternalLegendProp("numericBinSize"), + numericBinStroke: this.getExternalLegendProp("numericBinStroke"), + numericBinStrokeWidth: this.getExternalLegendProp( "numericBinStrokeWidth" ), - equalSizeBins: this.getNumericExternalLegendProp("equalSizeBins"), + equalSizeBins: this.getExternalLegendProp("equalSizeBins"), numericLegendData: this.numericLegendData, } } @@ -719,7 +700,7 @@ export class FacetChart return { ...this.commonLegendProps, categoryLegendY: this.bounds.top, - categoricalBinStroke: this.getCategoricalExternalLegendProp( + categoricalBinStroke: this.getExternalLegendProp( "categoricalBinStroke" ), categoricalLegendData: this.categoricalLegendData, @@ -749,14 +730,13 @@ export class FacetChart @computed get numericLegendData(): ColorScaleBin[] { if (!this.isNumericLegend || !this.hideFacetLegends) return [] - const uniqBins = this.getUniqBins([ - ...this.externalCategoricalLegends.flatMap( - (legend) => legend.categoricalLegendData - ), - ...this.externalNumericLegends.flatMap( - (legend) => legend.numericLegendData - ), - ]) + const allBins: ColorScaleBin[] = this.externalLegends.flatMap( + (legend) => [ + ...(legend.numericLegendData ?? []), + ...(legend.categoricalLegendData ?? []), + ] + ) + const uniqBins = this.getUniqBins(allBins) const sortedBins = sortBy( uniqBins, (bin) => bin instanceof CategoricalBin @@ -766,9 +746,12 @@ export class FacetChart @computed get categoricalLegendData(): CategoricalBin[] { if (this.isNumericLegend || !this.hideFacetLegends) return [] - const allBins = this.externalCategoricalLegends.flatMap( - (legend) => legend.categoricalLegendData - ) + const allBins: CategoricalBin[] = this.externalLegends + .flatMap((legend) => [ + ...(legend.numericLegendData ?? []), + ...(legend.categoricalLegendData ?? []), + ]) + .filter((bin) => bin instanceof CategoricalBin) as CategoricalBin[] const uniqBins = this.getUniqBins(allBins) const newBins = uniqBins.map( // remap index to ensure it's unique (the above procedure can lead to duplicates) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index 42eef7e07db..8b6ec9ca89f 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -293,17 +293,14 @@ describe("externalLegendBins", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart.externalCategoricalLegend).toBeUndefined() - expect(chart.externalNumericLegend).toBeUndefined() + expect(chart.externalLegend).toBeUndefined() }) it("exposes externalLegendBins when legend is hidden", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: false }, }) - expect( - chart.externalCategoricalLegend?.categoricalLegendData?.length - ).toEqual(2) + expect(chart.externalLegend?.categoricalLegendData?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 70cff50ca5c..b2db9170582 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -1487,10 +1487,14 @@ export class LineChart return this.dualAxis.horizontalAxis } - @computed get externalCategoricalLegend(): - | HorizontalCategoricalColorLegendProps + @computed get externalLegend(): + | (HorizontalNumericColorLegendProps & + HorizontalCategoricalColorLegendProps) | undefined { if (!this.manager.showLegend) { + const numericLegendData = this.hasColorScale + ? this.numericLegendData + : [] const categoricalLegendData = this.hasColorScale ? [] : this.series.map( @@ -1502,20 +1506,6 @@ export class LineChart color: series.color, }) ) - return { - categoricalLegendData, - } - } - return undefined - } - - @computed get externalNumericLegend(): - | HorizontalNumericColorLegendProps - | undefined { - if (!this.manager.showLegend) { - const numericLegendData = this.hasColorScale - ? this.numericLegendData - : [] return { legendTitle: this.legendTitle, legendTextColor: this.legendTextColor, @@ -1525,6 +1515,7 @@ export class LineChart numericBinStroke: this.numericBinStroke, numericBinStrokeWidth: this.numericBinStrokeWidth, numericLegendData, + categoricalLegendData, } } return undefined diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 2361ed36146..70689fb391c 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -591,7 +591,7 @@ export class SlopeChart : 0 } - @computed get externalCategoricalLegend(): + @computed get externalLegend(): | HorizontalCategoricalColorLegendProps | undefined { if (!this.manager.showLegend) { diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index e9b25f10939..8e2d0e19467 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -436,7 +436,7 @@ export class AbstractStackedChart return this.unstackedSeries } - @computed get externalCategoricalLegend(): + @computed get externalLegend(): | HorizontalCategoricalColorLegendProps | undefined { if (!this.manager.showLegend) { diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts index 18eda3f8f96..7f7dfd0bd02 100755 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts @@ -225,15 +225,13 @@ describe("externalLegendBins", () => { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart.externalCategoricalLegend).toBeUndefined() + expect(chart.externalLegend).toBeUndefined() }) it("exposes externalLegendBins when legend is hidden", () => { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: false }, }) - expect( - chart.externalCategoricalLegend?.categoricalLegendData?.length - ).toEqual(2) + expect(chart.externalLegend?.categoricalLegendData?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts index 7e1f653aecb..a34ee1a7a87 100755 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts @@ -373,7 +373,7 @@ describe("showLegend", () => { }) expect(chart["legendHeight"]).toBeGreaterThan(0) expect(chart["categoricalLegendData"].length).toBeGreaterThan(0) - expect(chart["externalCategoricalLegend"]).toBeUndefined() + expect(chart["externalLegend"]).toBeUndefined() }) it("exposes externalLegendBins when showLegend is false", () => { @@ -382,8 +382,8 @@ describe("showLegend", () => { }) expect(chart["legendHeight"]).toEqual(0) expect(chart["categoricalLegendData"].length).toEqual(0) - expect( - chart["externalCategoricalLegend"]?.categoricalLegendData?.length - ).toEqual(2) + expect(chart["externalLegend"]?.categoricalLegendData?.length).toEqual( + 2 + ) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index a3736388524..1feca4283b1 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -526,7 +526,7 @@ export class StackedDiscreteBarChart return this.showLegend ? this.legendBins : [] } - @computed get externalCategoricalLegend(): + @computed get externalLegend(): | HorizontalCategoricalColorLegendProps | undefined { if (!this.showLegend) { From 5fdebf42f5aa4ac5ea5a8442a7fc709ea77aae35 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 09:21:04 +0100 Subject: [PATCH 04/17] =?UTF-8?q?=F0=9F=94=A8=20refactor=20categorical=20l?= =?UTF-8?q?egend=20component=20into=20a=20functional=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...izontalCategoricalColorLegendComponent.tsx | 344 ++++++++++-------- 1 file changed, 188 insertions(+), 156 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx index 3e0432a23b7..2521dc9451e 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -1,7 +1,5 @@ import React from "react" import { HorizontalCategoricalColorLegend } from "./HorizontalCategoricalColorLegend" -import { computed } from "mobx" -import { observer } from "mobx-react" import { dyFromAlign, makeIdForHumanConsumption, @@ -12,8 +10,7 @@ import { OWID_NON_FOCUSED_GRAY } from "../color/ColorConstants" import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" import { ColorScaleBin } from "../color/ColorScaleBin" -@observer -export class HorizontalCategoricalColorLegendComponent extends React.Component<{ +interface HorizontalCategoricalColorLegendProps { legend: HorizontalCategoricalColorLegend legendOpacity?: number onLegendMouseLeave?: () => void @@ -23,159 +20,194 @@ export class HorizontalCategoricalColorLegendComponent extends React.Component<{ focusColors?: string[] // focused colors are bolded hoverColors?: string[] // non-hovered colors are muted activeColors?: string[] // inactive colors are grayed out -}> { - @computed private get legend(): HorizontalCategoricalColorLegend { - return this.props.legend - } - - renderLabels(): React.ReactElement { - const { marks } = this.legend - const { focusColors, hoverColors = [] } = this.props - - return ( - - {marks.map((mark, index) => { - const isFocus = focusColors?.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - return ( - + + + {isInteractive && ( + + )} + + ) +} + +function Labels({ + legend, + focusColors, + hoverColors = [], +}: { + legend: HorizontalCategoricalColorLegend + focusColors?: string[] // focused colors are bolded + hoverColors?: string[] // non-hovered colors are muted +}): React.ReactElement { + const { marks } = legend + + return ( + + {marks.map((mark, index) => { + const isFocus = focusColors?.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && + !hoverColors.includes(mark.bin.color) + + return ( + + {mark.label.text} + + ) + })} + + ) +} + +function Swatches({ + legend, + activeColors, + hoverColors = [], + legendOpacity, +}: { + legend: HorizontalCategoricalColorLegend + activeColors?: string[] // inactive colors are grayed out + hoverColors?: string[] // non-hovered colors are muted + legendOpacity?: number +}): React.ReactElement { + const { marks } = legend + const { categoricalBinStroke } = legend.props + + return ( + + {marks.map((mark, index) => { + const isActive = activeColors?.includes(mark.bin.color) + const isHovered = hoverColors.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && + !hoverColors.includes(mark.bin.color) + + const color = mark.bin.patternRef + ? `url(#${mark.bin.patternRef})` + : mark.bin.color + + const fill = + isHovered || isActive || activeColors === undefined + ? color + : OWID_NON_FOCUSED_GRAY + + const opacity = isNotHovered + ? GRAPHER_OPACITY_MUTE + : legendOpacity + + return ( + + ) + })} + + ) +} + +function InteractiveElements({ + legend, + onLegendClick, + onLegendMouseOver, + onLegendMouseLeave, +}: { + legend: HorizontalCategoricalColorLegend + onLegendMouseLeave?: () => void + onLegendMouseOver?: (d: ColorScaleBin) => void + onLegendClick?: (d: ColorScaleBin) => void +}): React.ReactElement { + const { marks } = legend + + return ( + + {marks.map((mark, index) => { + const mouseOver = (): void => + onLegendMouseOver ? onLegendMouseOver(mark.bin) : undefined + const mouseLeave = (): void => + onLegendMouseLeave ? onLegendMouseLeave() : undefined + const click = onLegendClick + ? (): void => onLegendClick?.(mark.bin) + : undefined + + const cursor = click ? "pointer" : "default" + + return ( + + {/* for hover interaction */} + - {mark.label.text} - - ) - })} - - ) - } - - renderSwatches(): React.ReactElement { - const { marks } = this.legend - const { categoricalBinStroke } = this.legend.props - const { legendOpacity, activeColors, hoverColors = [] } = this.props - - return ( - - {marks.map((mark, index) => { - const isActive = activeColors?.includes(mark.bin.color) - const isHovered = hoverColors.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - const color = mark.bin.patternRef - ? `url(#${mark.bin.patternRef})` - : mark.bin.color - - const fill = - isHovered || isActive || activeColors === undefined - ? color - : OWID_NON_FOCUSED_GRAY - - const opacity = isNotHovered - ? GRAPHER_OPACITY_MUTE - : legendOpacity - - return ( - - ) - })} - - ) - } - - renderInteractiveElements(): React.ReactElement { - const { props } = this - const { marks } = this.legend - - return ( - - {marks.map((mark, index) => { - const mouseOver = (): void => - props.onLegendMouseOver - ? props.onLegendMouseOver(mark.bin) - : undefined - const mouseLeave = (): void => - props.onLegendMouseLeave - ? props.onLegendMouseLeave() - : undefined - const click = props.onLegendClick - ? (): void => props.onLegendClick?.(mark.bin) - : undefined - - const cursor = click ? "pointer" : "default" - - return ( - - {/* for hover interaction */} - - - ) - })} - - ) - } - - render(): React.ReactElement { - const isInteractive = - this.props.onLegendClick || - this.props.onLegendMouseOver || - this.props.onLegendMouseLeave - - return ( - - {this.renderSwatches()} - {this.renderLabels()} - {isInteractive && this.renderInteractiveElements()} - - ) - } + + ) + })} + + ) } From e3dfb4bd83d70580f52db1f99dfa8dfe6d3f4dba Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 09:56:41 +0100 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=94=A8=20remove=20abstract=20horizo?= =?UTF-8?q?ntal=20color=20legend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/barCharts/DiscreteBarChart.tsx | 6 +-- .../grapher/src/facetChart/FacetChart.tsx | 10 ++--- .../AbstractHorizontalColorLegend.ts | 39 ------------------ .../HorizontalCategoricalColorLegend.ts | 35 +++++++++++----- ...izontalCategoricalColorLegendComponent.tsx | 17 ++++++-- .../HorizontalNumericColorLegend.ts | 40 ++++++++++++------- .../grapher/src/lineCharts/LineChart.tsx | 6 +-- .../grapher/src/mapCharts/MapChart.tsx | 24 +++++------ .../src/stackedCharts/MarimekkoChart.tsx | 10 ++--- .../src/stackedCharts/StackedBarChart.tsx | 10 ++--- .../stackedCharts/StackedDiscreteBarChart.tsx | 10 ++--- 11 files changed, 102 insertions(+), 105 deletions(-) delete mode 100644 packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 8a1f921e6b4..877c205fff3 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -823,9 +823,9 @@ export class DiscreteBarChart private get legendProps(): HorizontalNumericColorLegendProps { return { fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, - legendMaxWidth: this.legendMaxWidth, + x: this.legendX, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, numericLegendData: this.numericLegendData, numericBinSize: this.numericBinSize, numericBinStroke: this.numericBinStroke, diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index d6a8e8e99b8..ca11fbf47fb 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -67,7 +67,6 @@ import { HorizontalNumericColorLegend, HorizontalNumericColorLegendProps, } from "../horizontalColorLegend/HorizontalNumericColorLegend" -import { HorizontalColorLegendProps } from "../horizontalColorLegend/AbstractHorizontalColorLegend" import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" @@ -668,12 +667,13 @@ export class FacetChart // legend props - @computed private get commonLegendProps(): HorizontalColorLegendProps { + @computed + private get commonLegendProps(): ExternalLegendProps { return { fontSize: this.fontSize, - legendX: this.bounds.x, - legendMaxWidth: this.bounds.width, - legendAlign: HorizontalAlign.left, + x: this.bounds.x, + maxWidth: this.bounds.width, + align: HorizontalAlign.left, } } diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts deleted file mode 100644 index e519bdd69a8..00000000000 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/AbstractHorizontalColorLegend.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { computed } from "mobx" -import { HorizontalAlign } from "@ourworldindata/types" -import { BASE_FONT_SIZE } from "../core/GrapherConstants" - -export interface HorizontalColorLegendProps { - fontSize?: number - legendX?: number - legendAlign?: HorizontalAlign - legendMaxWidth?: number -} - -export abstract class AbstractHorizontalColorLegend< - Props extends HorizontalColorLegendProps, -> { - props: Props - constructor(props: Props) { - this.props = props - } - - @computed get legendX(): number { - return this.props.legendX ?? 0 - } - - @computed protected get legendAlign(): HorizontalAlign { - // Assume center alignment if none specified, for backwards-compatibility - return this.props.legendAlign ?? HorizontalAlign.center - } - - @computed protected get fontSize(): number { - return this.props.fontSize ?? BASE_FONT_SIZE - } - - @computed protected get legendMaxWidth(): number | undefined { - return this.props.legendMaxWidth - } - - abstract get height(): number - abstract get width(): number -} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts index 62dce0aa8c5..b6f454c14f9 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -1,15 +1,16 @@ import { computed } from "mobx" import { max, Bounds, Color, HorizontalAlign } from "@ourworldindata/utils" import { CategoricalBin } from "../color/ColorScaleBin" -import { GRAPHER_FONT_SCALE_12_8 } from "../core/GrapherConstants" -import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" import { - AbstractHorizontalColorLegend, - HorizontalColorLegendProps, -} from "./AbstractHorizontalColorLegend" + BASE_FONT_SIZE, + GRAPHER_FONT_SCALE_12_8, +} from "../core/GrapherConstants" +import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" -export interface HorizontalCategoricalColorLegendProps - extends HorizontalColorLegendProps { +export interface HorizontalCategoricalColorLegendProps { + fontSize?: number + align?: HorizontalAlign + maxWidth?: number categoricalLegendData: CategoricalBin[] categoricalBinStroke?: Color categoryLegendY?: number @@ -33,10 +34,15 @@ interface MarkLine { marks: CategoricalMark[] } -export class HorizontalCategoricalColorLegend extends AbstractHorizontalColorLegend { +export class HorizontalCategoricalColorLegend { rectPadding = 5 private markPadding = 5 + props: HorizontalCategoricalColorLegendProps + constructor(props: HorizontalCategoricalColorLegendProps) { + this.props = props + } + static height(props: HorizontalCategoricalColorLegendProps): number { const legend = new HorizontalCategoricalColorLegend(props) return legend.height @@ -52,7 +58,16 @@ export class HorizontalCategoricalColorLegend extends AbstractHorizontalColorLeg } @computed get width(): number { - return this.legendMaxWidth ?? 200 + return this.props.maxWidth ?? 200 + } + + @computed private get fontSize(): number { + return this.props.fontSize ?? BASE_FONT_SIZE + } + + @computed protected get align(): HorizontalAlign { + // Assume center alignment if none specified, for backwards-compatibility + return this.props.align ?? HorizontalAlign.center } @computed private get categoricalLegendData(): CategoricalBin[] { @@ -129,7 +144,7 @@ export class HorizontalCategoricalColorLegend extends AbstractHorizontalColorLeg @computed get marks(): CategoricalMark[] { const lines = this.markLines - const align = this.legendAlign + const align = this.align const width = this.containerWidth // Center each line diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx index 2521dc9451e..5c2c8e5cd65 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -12,6 +12,7 @@ import { ColorScaleBin } from "../color/ColorScaleBin" interface HorizontalCategoricalColorLegendProps { legend: HorizontalCategoricalColorLegend + x?: number legendOpacity?: number onLegendMouseLeave?: () => void onLegendMouseOver?: (d: ColorScaleBin) => void @@ -25,6 +26,7 @@ interface HorizontalCategoricalColorLegendProps { export function HorizontalCategoricalColorLegendComponent({ legend, legendOpacity, + x = 0, onLegendClick, onLegendMouseOver, onLegendMouseLeave, @@ -42,17 +44,20 @@ export function HorizontalCategoricalColorLegendComponent({ > {isInteractive && ( void onLegendMouseOver?: (d: ColorScaleBin) => void onLegendClick?: (d: ColorScaleBin) => void @@ -194,7 +205,7 @@ function InteractiveElements({ > {/* for hover interaction */} { +export class HorizontalNumericColorLegend { + props: HorizontalNumericColorLegendProps + constructor(props: HorizontalNumericColorLegendProps) { + this.props = props + } + static height(props: HorizontalNumericColorLegendProps): number { const legend = new HorizontalNumericColorLegend(props) return legend.height @@ -101,6 +106,10 @@ export class HorizontalNumericColorLegend extends AbstractHorizontalColorLegend< ) } + @computed protected get fontSize(): number { + return this.props.fontSize ?? BASE_FONT_SIZE + } + @computed private get tickFontSize(): number { return GRAPHER_FONT_SCALE_12 * this.fontSize } @@ -122,7 +131,7 @@ export class HorizontalNumericColorLegend extends AbstractHorizontalColorLegend< } @computed private get maxWidth(): number { - return this.props.legendMaxWidth ?? this.props.legendWidth ?? 200 + return this.props.maxWidth ?? this.props.legendWidth ?? 200 } private getTickLabelWidth(label: string): number { @@ -151,7 +160,7 @@ export class HorizontalNumericColorLegend extends AbstractHorizontalColorLegend< @computed private get isAutoWidth(): boolean { return ( this.props.legendWidth === undefined && - this.props.legendMaxWidth !== undefined + this.props.maxWidth !== undefined ) } @@ -223,14 +232,15 @@ export class HorizontalNumericColorLegend extends AbstractHorizontalColorLegend< // Since we calculate the width automatically in some cases (when `isAutoWidth` is true), // we need to shift X to align the legend horizontally (`legendAlign`). @computed get x(): number { - const { width, maxWidth, legendAlign, legendX } = this + const { width, maxWidth, x } = this + const { align } = this.props const widthDiff = maxWidth - width - if (legendAlign === HorizontalAlign.center) { - return legendX + widthDiff / 2 - } else if (legendAlign === HorizontalAlign.right) { - return legendX + widthDiff + if (align === HorizontalAlign.center) { + return x + widthDiff / 2 + } else if (align === HorizontalAlign.right) { + return x + widthDiff } else { - return legendX // left align + return x // left align } } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index b2db9170582..4f81aab7fe5 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -1172,9 +1172,9 @@ export class LineChart @computed get colorLegendProps(): HorizontalNumericColorLegendProps { return { fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, - legendMaxWidth: this.legendMaxWidth, + x: this.legendX, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, numericLegendData: this.numericLegendData, numericBinSize: this.numericBinSize, numericBinStroke: this.numericBinStroke, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 5a4263bf3e4..469f54f59ec 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -538,10 +538,9 @@ export class MapChart return this.hasCategoryLegend ? new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, + align: this.legendAlign, categoryLegendY: this.categoryLegendY, - legendMaxWidth: this.legendMaxWidth, + maxWidth: this.legendMaxWidth, categoricalLegendData: this.categoricalLegendData, categoricalBinStroke: this.categoricalBinStroke, }) @@ -554,10 +553,10 @@ export class MapChart return this.hasNumericLegend ? new HorizontalNumericColorLegend({ fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, + x: this.legendX, + align: this.legendAlign, numericLegendY: this.numericLegendY, - legendMaxWidth: this.legendMaxWidth, + maxWidth: this.legendMaxWidth, numericLegendData: this.numericLegendData, numericFocusBracket: this.numericFocusBracket, equalSizeBins: this.equalSizeBins, @@ -569,8 +568,8 @@ export class MapChart return this.hasCategoryLegend ? HorizontalCategoricalColorLegend.height({ fontSize: this.fontSize, - legendAlign: this.legendAlign, - legendMaxWidth: this.legendMaxWidth, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, categoricalLegendData: this.categoricalLegendData, }) + 5 : 0 @@ -580,7 +579,7 @@ export class MapChart return this.hasCategoryLegend ? HorizontalCategoricalColorLegend.numLines({ fontSize: this.fontSize, - legendMaxWidth: this.legendMaxWidth, + maxWidth: this.legendMaxWidth, categoricalLegendData: this.categoricalLegendData, }) : 0 @@ -590,9 +589,9 @@ export class MapChart return this.hasNumericLegend ? HorizontalNumericColorLegend.height({ fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, - legendMaxWidth: this.legendMaxWidth, + x: this.legendX, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, numericLegendData: this.numericLegendData, equalSizeBins: this.equalSizeBins, }) @@ -654,6 +653,7 @@ export class MapChart {this.categoryLegend && ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index 3bf1ed95b9f..ebda9d03224 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -898,8 +898,8 @@ export class MarimekkoChart @computed private get legendHeight(): number { return HorizontalCategoricalColorLegend.height({ fontSize: this.fontSize, - legendAlign: this.legendAlign, - legendMaxWidth: this.legendWidth, + align: this.legendAlign, + maxWidth: this.legendWidth, categoricalLegendData: this.categoricalLegendData, }) } @@ -907,10 +907,9 @@ export class MarimekkoChart @computed get legend(): HorizontalCategoricalColorLegend { return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, - legendX: this.legendX, - legendAlign: this.legendAlign, + align: this.legendAlign, categoryLegendY: this.categoryLegendY, - legendMaxWidth: this.legendWidth, + maxWidth: this.legendWidth, categoricalLegendData: this.categoricalLegendData, }) } @@ -1069,6 +1068,7 @@ export class MarimekkoChart /> From b16eeb8ae4cbccb7b46bd7137ec9a460e5c3aaff Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 11:01:35 +0100 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=94=A8=20rename=20horizontal=20colo?= =?UTF-8?q?r=20legend=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/barCharts/DiscreteBarChart.tsx | 16 +-- .../grapher/src/facetChart/FacetChart.tsx | 44 ++++---- .../HorizontalCategoricalColorLegend.ts | 78 +++++++------- ...izontalCategoricalColorLegendComponent.tsx | 102 ++++++++++-------- .../HorizontalColorLegends.test.ts | 6 +- .../HorizontalNumericColorLegend.ts | 75 +++++-------- .../HorizontalNumericColorLegendComponent.tsx | 87 +++++++++------ .../grapher/src/lineCharts/LineChart.test.ts | 2 +- .../grapher/src/lineCharts/LineChart.tsx | 33 +++--- .../grapher/src/mapCharts/MapChart.tsx | 26 ++--- .../grapher/src/slopeCharts/SlopeChart.tsx | 2 +- .../stackedCharts/AbstractStackedChart.tsx | 2 +- .../src/stackedCharts/MarimekkoChart.tsx | 12 +-- .../stackedCharts/StackedAreaChart.test.ts | 2 +- .../src/stackedCharts/StackedBarChart.tsx | 10 +- .../StackedDiscreteBarChart.test.ts | 4 +- .../stackedCharts/StackedDiscreteBarChart.tsx | 12 +-- 17 files changed, 259 insertions(+), 254 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 877c205fff3..f590f248d8f 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -508,6 +508,8 @@ export class DiscreteBarChart {this.showColorLegend && ( )} {!this.isLogScale && ( @@ -826,14 +828,12 @@ export class DiscreteBarChart x: this.legendX, align: this.legendAlign, maxWidth: this.legendMaxWidth, - numericLegendData: this.numericLegendData, - numericBinSize: this.numericBinSize, - numericBinStroke: this.numericBinStroke, + numericBins: this.numericLegendData, + binSize: this.numericBinSize, equalSizeBins: this.equalSizeBins, - legendTitle: this.legendTitle, - numericLegendY: this.numericLegendY, - legendTextColor: this.legendTextColor, - legendTickSize: this.legendTickSize, + title: this.legendTitle, + y: this.numericLegendY, + tickSize: this.legendTickSize, } } @@ -853,7 +853,7 @@ export class DiscreteBarChart | undefined { if (this.hasColorLegend) { return { - numericLegendData: this.numericLegendData, + numericBins: this.numericLegendData, } } return undefined diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index ca11fbf47fb..373f5ebae31 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -607,7 +607,7 @@ export class FacetChart @computed private get isNumericLegend(): boolean { return this.externalLegends.some((legend) => - legend.numericLegendData?.some((bin) => bin instanceof NumericBin) + legend.numericBins?.some((bin) => bin instanceof NumericBin) ) } @@ -681,17 +681,12 @@ export class FacetChart private get numericLegendProps(): HorizontalNumericColorLegendProps { return { ...this.commonLegendProps, - numericLegendY: this.bounds.top, - legendTitle: this.getExternalLegendProp("legendTitle"), - legendTextColor: this.getExternalLegendProp("legendTextColor"), - legendTickSize: this.getExternalLegendProp("legendTickSize"), - numericBinSize: this.getExternalLegendProp("numericBinSize"), - numericBinStroke: this.getExternalLegendProp("numericBinStroke"), - numericBinStrokeWidth: this.getExternalLegendProp( - "numericBinStrokeWidth" - ), + y: this.bounds.top, + title: this.getExternalLegendProp("title"), + tickSize: this.getExternalLegendProp("tickSize"), + binSize: this.getExternalLegendProp("binSize"), equalSizeBins: this.getExternalLegendProp("equalSizeBins"), - numericLegendData: this.numericLegendData, + numericBins: this.numericLegendData, } } @@ -699,11 +694,7 @@ export class FacetChart private get categoricalLegendProps(): HorizontalCategoricalColorLegendProps { return { ...this.commonLegendProps, - categoryLegendY: this.bounds.top, - categoricalBinStroke: this.getExternalLegendProp( - "categoricalBinStroke" - ), - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, } } @@ -732,8 +723,8 @@ export class FacetChart if (!this.isNumericLegend || !this.hideFacetLegends) return [] const allBins: ColorScaleBin[] = this.externalLegends.flatMap( (legend) => [ - ...(legend.numericLegendData ?? []), - ...(legend.categoricalLegendData ?? []), + ...(legend.numericBins ?? []), + ...(legend.categoricalBins ?? []), ] ) const uniqBins = this.getUniqBins(allBins) @@ -748,8 +739,8 @@ export class FacetChart if (this.isNumericLegend || !this.hideFacetLegends) return [] const allBins: CategoricalBin[] = this.externalLegends .flatMap((legend) => [ - ...(legend.numericLegendData ?? []), - ...(legend.categoricalLegendData ?? []), + ...(legend.numericBins ?? []), + ...(legend.categoricalBins ?? []), ]) .filter((bin) => bin instanceof CategoricalBin) as CategoricalBin[] const uniqBins = this.getUniqBins(allBins) @@ -860,17 +851,20 @@ export class FacetChart return this.isNumericLegend ? ( ) : ( ) } diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts index b6f454c14f9..89ec6f9b000 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -1,5 +1,5 @@ import { computed } from "mobx" -import { max, Bounds, Color, HorizontalAlign } from "@ourworldindata/utils" +import { max, Bounds, HorizontalAlign } from "@ourworldindata/utils" import { CategoricalBin } from "../color/ColorScaleBin" import { BASE_FONT_SIZE, @@ -8,18 +8,15 @@ import { import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" export interface HorizontalCategoricalColorLegendProps { - fontSize?: number - align?: HorizontalAlign + categoricalBins: CategoricalBin[] maxWidth?: number - categoricalLegendData: CategoricalBin[] - categoricalBinStroke?: Color - categoryLegendY?: number + align?: HorizontalAlign + fontSize?: number } interface CategoricalMark { x: number y: number - rectSize: number width: number label: { text: string @@ -35,8 +32,11 @@ interface MarkLine { } export class HorizontalCategoricalColorLegend { - rectPadding = 5 - private markPadding = 5 + /** Margin between the swatch and the label */ + swatchMarginRight = 5 + + /** Horizontal space between two bins */ + horizontalBinMargin = 5 props: HorizontalCategoricalColorLegendProps constructor(props: HorizontalCategoricalColorLegendProps) { @@ -53,55 +53,53 @@ export class HorizontalCategoricalColorLegend { return legend.numLines } - @computed get categoryLegendY(): number { - return this.props.categoryLegendY ?? 0 - } - - @computed get width(): number { + @computed private get maxWidth(): number { return this.props.maxWidth ?? 200 } @computed private get fontSize(): number { - return this.props.fontSize ?? BASE_FONT_SIZE + return GRAPHER_FONT_SCALE_12_8 * (this.props.fontSize ?? BASE_FONT_SIZE) + } + + @computed get swatchSize(): number { + return this.fontSize * 0.75 } - @computed protected get align(): HorizontalAlign { - // Assume center alignment if none specified, for backwards-compatibility + @computed private get align(): HorizontalAlign { return this.props.align ?? HorizontalAlign.center } - @computed private get categoricalLegendData(): CategoricalBin[] { - return this.props.categoricalLegendData ?? [] + @computed private get bins(): CategoricalBin[] { + return this.props.categoricalBins ?? [] } - @computed private get visibleCategoricalBins(): CategoricalBin[] { - return this.categoricalLegendData.filter((bin) => !bin.isHidden) + @computed private get visibleBins(): CategoricalBin[] { + return this.bins.filter((bin) => !bin.isHidden) } @computed private get markLines(): MarkLine[] { - const fontSize = this.fontSize * GRAPHER_FONT_SCALE_12_8 - const rectSize = this.fontSize * 0.75 - const lines: MarkLine[] = [] let marks: CategoricalMark[] = [] let xOffset = 0 let yOffset = 0 - this.visibleCategoricalBins.forEach((bin) => { - const labelBounds = Bounds.forText(bin.text, { fontSize }) + this.visibleBins.forEach((bin) => { + const labelBounds = Bounds.forText(bin.text, { + fontSize: this.fontSize, + }) const markWidth = - rectSize + - this.rectPadding + + this.swatchSize + + this.swatchMarginRight + labelBounds.width + - this.markPadding + this.horizontalBinMargin - if (xOffset + markWidth > this.width && marks.length > 0) { + if (xOffset + markWidth > this.maxWidth && marks.length > 0) { lines.push({ - totalWidth: xOffset - this.markPadding, + totalWidth: xOffset - this.horizontalBinMargin, marks: marks, }) marks = [] xOffset = 0 - yOffset += rectSize + this.rectPadding + yOffset += this.swatchSize + this.swatchMarginRight } const markX = xOffset @@ -110,17 +108,16 @@ export class HorizontalCategoricalColorLegend { const label = { text: bin.text, bounds: labelBounds.set({ - x: markX + rectSize + this.rectPadding, - y: markY + rectSize / 2, + x: markX + this.swatchSize + this.swatchMarginRight, + y: markY + this.swatchSize / 2, }), - fontSize, + fontSize: this.fontSize, } marks.push({ x: markX, y: markY, width: markWidth, - rectSize, label, bin, }) @@ -129,7 +126,10 @@ export class HorizontalCategoricalColorLegend { }) if (marks.length > 0) - lines.push({ totalWidth: xOffset - this.markPadding, marks: marks }) + lines.push({ + totalWidth: xOffset - this.horizontalBinMargin, + marks: marks, + }) return lines } @@ -139,7 +139,7 @@ export class HorizontalCategoricalColorLegend { } @computed private get containerWidth(): number { - return this.width ?? this.contentWidth + return this.maxWidth ?? this.contentWidth } @computed get marks(): CategoricalMark[] { @@ -168,7 +168,7 @@ export class HorizontalCategoricalColorLegend { } @computed get height(): number { - return max(this.marks.map((mark) => mark.y + mark.rectSize)) ?? 0 + return max(this.marks.map((mark) => mark.y + this.swatchSize)) ?? 0 } @computed get numLines(): number { diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx index 5c2c8e5cd65..ee0f4b0228a 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -1,6 +1,7 @@ import React from "react" import { HorizontalCategoricalColorLegend } from "./HorizontalCategoricalColorLegend" import { + Color, dyFromAlign, makeIdForHumanConsumption, VerticalAlign, @@ -12,30 +13,40 @@ import { ColorScaleBin } from "../color/ColorScaleBin" interface HorizontalCategoricalColorLegendProps { legend: HorizontalCategoricalColorLegend + + // positioning x?: number - legendOpacity?: number - onLegendMouseLeave?: () => void - onLegendMouseOver?: (d: ColorScaleBin) => void - onLegendClick?: (d: ColorScaleBin) => void + y?: number + + // presentation + opacity?: number + swatchStrokeColor?: Color + // state focusColors?: string[] // focused colors are bolded hoverColors?: string[] // non-hovered colors are muted activeColors?: string[] // inactive colors are grayed out + + // interaction + onMouseLeave?: () => void + onMouseOver?: (d: ColorScaleBin) => void + onClick?: (d: ColorScaleBin) => void } export function HorizontalCategoricalColorLegendComponent({ legend, - legendOpacity, x = 0, - onLegendClick, - onLegendMouseOver, - onLegendMouseLeave, + y = 0, + opacity, + swatchStrokeColor, focusColors, hoverColors, activeColors, + onClick, + onMouseOver, + onMouseLeave, }: HorizontalCategoricalColorLegendProps): React.ReactElement { - const isInteractive = - onLegendClick || onLegendMouseOver || onLegendMouseLeave + const isInteractive = onClick || onMouseOver || onMouseLeave return ( {isInteractive && ( )} @@ -70,11 +85,13 @@ export function HorizontalCategoricalColorLegendComponent({ function Labels({ x, + y, legend, focusColors, hoverColors = [], }: { x: number + y: number legend: HorizontalCategoricalColorLegend focusColors?: string[] // focused colors are bolded hoverColors?: string[] // non-hovered colors are muted @@ -93,7 +110,7 @@ function Labels({ @@ -144,22 +164,18 @@ function Swatches({ ? color : OWID_NON_FOCUSED_GRAY - const opacity = isNotHovered - ? GRAPHER_OPACITY_MUTE - : legendOpacity - return ( ) })} @@ -170,15 +186,17 @@ function Swatches({ function InteractiveElements({ legend, x, - onLegendClick, - onLegendMouseOver, - onLegendMouseLeave, + y, + onClick, + onMouseOver, + onMouseLeave, }: { legend: HorizontalCategoricalColorLegend x: number - onLegendMouseLeave?: () => void - onLegendMouseOver?: (d: ColorScaleBin) => void - onLegendClick?: (d: ColorScaleBin) => void + y: number + onMouseLeave?: () => void + onMouseOver?: (d: ColorScaleBin) => void + onClick?: (d: ColorScaleBin) => void }): React.ReactElement { const { marks } = legend @@ -186,11 +204,11 @@ function InteractiveElements({ {marks.map((mark, index) => { const mouseOver = (): void => - onLegendMouseOver ? onLegendMouseOver(mark.bin) : undefined + onMouseOver ? onMouseOver(mark.bin) : undefined const mouseLeave = (): void => - onLegendMouseLeave ? onLegendMouseLeave() : undefined - const click = onLegendClick - ? (): void => onLegendClick?.(mark.bin) + onMouseLeave ? onMouseLeave() : undefined + const click = onClick + ? (): void => onClick?.(mark.bin) : undefined const cursor = click ? "pointer" : "default" @@ -206,12 +224,10 @@ function InteractiveElements({ {/* for hover interaction */} { }) const legend = new HorizontalNumericColorLegend({ - numericLegendData: [bin], + numericBins: [bin], }) expect(legend.height).toBeGreaterThan(0) }) it("adds margins between categorical but not numeric bins", () => { const legend = new HorizontalNumericColorLegend({ - numericLegendData: [ + numericBins: [ new CategoricalBin({ index: 0, value: "a", @@ -98,7 +98,7 @@ describe(HorizontalCategoricalColorLegend, () => { }) const legend = new HorizontalCategoricalColorLegend({ - categoricalLegendData: [bin], + categoricalBins: [bin], }) expect(legend.height).toBeGreaterThan(0) }) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts index 8635631ecbc..70d996cc34a 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts @@ -1,4 +1,4 @@ -import { Color, HorizontalAlign } from "@ourworldindata/types" +import { HorizontalAlign } from "@ourworldindata/types" import { CategoricalBin, ColorScaleBin, @@ -8,9 +8,6 @@ import { computed } from "mobx" import { CATEGORICAL_BIN_MIN_WIDTH, DEFAULT_NUMERIC_BIN_SIZE, - DEFAULT_NUMERIC_BIN_STROKE, - DEFAULT_NUMERIC_BIN_STROKE_WIDTH, - DEFAULT_TEXT_COLOR, DEFAULT_TICK_SIZE, MINIMUM_LABEL_DISTANCE, } from "./HorizontalColorLegendConstants" @@ -23,21 +20,17 @@ import { Bounds, last, max, min, sum } from "@ourworldindata/utils" import { TextWrap } from "@ourworldindata/components" export interface HorizontalNumericColorLegendProps { - fontSize?: number + numericBins: ColorScaleBin[] x?: number - align?: HorizontalAlign + y?: number + width?: number maxWidth?: number - numericLegendData: ColorScaleBin[] - numericBinSize?: number - numericBinStroke?: Color - numericBinStrokeWidth?: number + align?: HorizontalAlign + fontSize?: number + binSize?: number equalSizeBins?: boolean - legendWidth?: number - legendTitle?: string - numericFocusBracket?: ColorScaleBin - numericLegendY?: number - legendTextColor?: Color - legendTickSize?: number + title?: string + tickSize?: number } interface NumericLabel { @@ -66,24 +59,20 @@ export class HorizontalNumericColorLegend { return legend.height } - @computed get numericLegendY(): number { - return this.props.numericLegendY ?? 0 - } - - @computed get legendTextColor(): Color { - return this.props.legendTextColor ?? DEFAULT_TEXT_COLOR + @computed get y(): number { + return this.props.y ?? 0 } - @computed private get legendTickSize(): number { - return this.props.legendTickSize ?? DEFAULT_TICK_SIZE + @computed private get tickSize(): number { + return this.props.tickSize ?? DEFAULT_TICK_SIZE } - @computed private get numericLegendData(): ColorScaleBin[] { - return this.props.numericLegendData ?? [] + @computed private get bins(): ColorScaleBin[] { + return this.props.numericBins ?? [] } @computed private get visibleBins(): ColorScaleBin[] { - return this.numericLegendData.filter((bin) => !bin.isHidden) + return this.bins.filter((bin) => !bin.isHidden) } @computed private get numericBins(): NumericBin[] { @@ -92,18 +81,8 @@ export class HorizontalNumericColorLegend { ) } - @computed get numericBinSize(): number { - return this.props.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE - } - - @computed get numericBinStroke(): Color { - return this.props.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE - } - - @computed get numericBinStrokeWidth(): number { - return ( - this.props.numericBinStrokeWidth ?? DEFAULT_NUMERIC_BIN_STROKE_WIDTH - ) + @computed get binSize(): number { + return this.props.binSize ?? DEFAULT_NUMERIC_BIN_SIZE } @computed protected get fontSize(): number { @@ -131,7 +110,7 @@ export class HorizontalNumericColorLegend { } @computed private get maxWidth(): number { - return this.props.maxWidth ?? this.props.legendWidth ?? 200 + return this.props.maxWidth ?? this.props.width ?? 200 } private getTickLabelWidth(label: string): number { @@ -159,8 +138,7 @@ export class HorizontalNumericColorLegend { @computed private get isAutoWidth(): boolean { return ( - this.props.legendWidth === undefined && - this.props.maxWidth !== undefined + this.props.width === undefined && this.props.maxWidth !== undefined ) } @@ -232,7 +210,8 @@ export class HorizontalNumericColorLegend { // Since we calculate the width automatically in some cases (when `isAutoWidth` is true), // we need to shift X to align the legend horizontally (`legendAlign`). @computed get x(): number { - const { width, maxWidth, x } = this + const { x = 0 } = this.props + const { width, maxWidth } = this const { align } = this.props const widthDiff = maxWidth - width if (align === HorizontalAlign.center) { @@ -294,7 +273,7 @@ export class HorizontalNumericColorLegend { } @computed get legendTitle(): TextWrap | undefined { - const { legendTitle } = this.props + const { title: legendTitle } = this.props return legendTitle ? new TextWrap({ text: legendTitle, @@ -311,7 +290,7 @@ export class HorizontalNumericColorLegend { } @computed get numericLabels(): NumericLabel[] { - const { numericBinSize, positionedBins, tickFontSize } = this + const { binSize: numericBinSize, positionedBins, tickFontSize } = this const makeBoundaryLabel = ( bin: PositionedBin, @@ -323,7 +302,7 @@ export class HorizontalNumericColorLegend { bin.x + (minOrMax === "min" ? 0 : bin.width) - labelBounds.width / 2 - const y = -numericBinSize - labelBounds.height - this.legendTickSize + const y = -numericBinSize - labelBounds.height - this.tickSize return { text: text, @@ -339,7 +318,7 @@ export class HorizontalNumericColorLegend { fontSize: tickFontSize, }) const x = bin.x + bin.width / 2 - labelBounds.width / 2 - const y = -numericBinSize - labelBounds.height - this.legendTickSize + const y = -numericBinSize - labelBounds.height - this.tickSize return { text: bin.bin.text, @@ -414,6 +393,6 @@ export class HorizontalNumericColorLegend { } @computed get bounds(): Bounds { - return new Bounds(this.x, this.numericLegendY, this.width, this.height) + return new Bounds(this.x, this.y, this.width, this.height) } } diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx index 0005c1825fd..0f7b64305a5 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx @@ -2,6 +2,7 @@ import React from "react" import { HorizontalNumericColorLegend } from "./HorizontalNumericColorLegend" import { action, computed } from "mobx" import { + Color, dyFromAlign, getRelativeMouse, makeIdForHumanConsumption, @@ -12,15 +13,25 @@ import { import { ColorScaleBin, NumericBin } from "../color/ColorScaleBin" import { darkenColorForLine } from "../color/ColorUtils" import { observer } from "mobx-react" +import { + DEFAULT_NUMERIC_BIN_STROKE, + DEFAULT_NUMERIC_BIN_STROKE_WIDTH, + DEFAULT_TEXT_COLOR, +} from "./HorizontalColorLegendConstants" const FOCUS_BORDER_COLOR = "#111" @observer export class HorizontalNumericColorLegendComponent extends React.Component<{ legend: HorizontalNumericColorLegend - legendOpacity?: number - onLegendMouseLeave?: () => void - onLegendMouseOver?: (d: ColorScaleBin) => void + x?: number + focusBin?: ColorScaleBin + textColor?: Color + binStrokeColor?: Color + binStrokeWidth?: number + opacity?: number + onMouseLeave?: () => void + onMouseOver?: (d: ColorScaleBin) => void }> { base: React.RefObject = React.createRef() @@ -28,11 +39,26 @@ export class HorizontalNumericColorLegendComponent extends React.Component<{ return this.props.legend } + @computed get binStrokeColor(): Color { + return this.props.binStrokeColor ?? DEFAULT_NUMERIC_BIN_STROKE + } + + @computed get binStrokeWidth(): number { + return this.props.binStrokeWidth ?? DEFAULT_NUMERIC_BIN_STROKE_WIDTH + } + + @computed get textColor(): Color { + return this.props.textColor ?? DEFAULT_TEXT_COLOR + } + @action.bound private onMouseMove(ev: MouseEvent | TouchEvent): void { const { base } = this const { positionedBins } = this.legend - const { numericFocusBracket } = this.props.legend.props // TODO - const { onLegendMouseLeave, onLegendMouseOver } = this.props + const { focusBin } = this.props + const { + onMouseLeave: onLegendMouseLeave, + onMouseOver: onLegendMouseOver, + } = this.props if (base.current) { const mouse = getRelativeMouse(base.current, ev) @@ -45,20 +71,18 @@ export class HorizontalNumericColorLegendComponent extends React.Component<{ // If outside legend bounds, trigger onMouseLeave if there is an existing bin in focus. if (!this.legend.bounds.contains(mouse)) { - if (numericFocusBracket && onLegendMouseLeave) - return onLegendMouseLeave() + if (focusBin && onLegendMouseLeave) return onLegendMouseLeave() return } // If inside legend bounds, trigger onMouseOver with the bin closest to the cursor. - let newFocusBracket: ColorScaleBin | undefined + let newFocusBin: ColorScaleBin | undefined positionedBins.forEach((bin) => { if (mouse.x >= bin.x && mouse.x <= bin.x + bin.width) - newFocusBracket = bin.bin + newFocusBin = bin.bin }) - if (newFocusBracket && onLegendMouseOver) - onLegendMouseOver(newFocusBracket) + if (newFocusBin && onLegendMouseOver) onLegendMouseOver(newFocusBin) } } @@ -79,14 +103,11 @@ export class HorizontalNumericColorLegendComponent extends React.Component<{ } render(): React.ReactElement { - const { numericLabels, numericBinSize, positionedBins, height } = - this.legend - const { numericFocusBracket } = this.legend.props - const { legendOpacity } = this.props + const { binStrokeColor, binStrokeWidth } = this + const { numericLabels, binSize, positionedBins, height } = this.legend + const { focusBin, opacity } = this.props - const stroke = this.legend.numericBinStroke - const strokeWidth = this.legend.numericBinStrokeWidth - const bottomY = this.legend.numericLegendY + height + const bottomY = this.legend.y + height return ( ))} @@ -118,26 +139,26 @@ export class HorizontalNumericColorLegendComponent extends React.Component<{ {sortBy( positionedBins.map((positionedBin, index) => { const bin = positionedBin.bin - const isFocus = - numericFocusBracket && - bin.equals(numericFocusBracket) + const isFocus = focusBin && bin.equals(focusBin) return ( {label.text} @@ -174,11 +195,11 @@ export class HorizontalNumericColorLegendComponent extends React.Component<{ {this.legend.legendTitle?.render( this.legend.x, // Align legend title baseline with bottom of color bins - this.legend.numericLegendY + + this.legend.y + height - this.legend.legendTitle.height + this.legend.legendTitleFontSize * 0.2, - { textProps: { fill: this.legend.legendTextColor } } + { textProps: { fill: this.textColor } } )} ) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index 8b6ec9ca89f..169e5989f01 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -300,7 +300,7 @@ describe("externalLegendBins", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart.externalLegend?.categoricalLegendData?.length).toEqual(2) + expect(chart.externalLegend?.bins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 4f81aab7fe5..346a87f85cf 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -932,6 +932,9 @@ export class LineChart return ( ) } @@ -1175,15 +1178,12 @@ export class LineChart x: this.legendX, align: this.legendAlign, maxWidth: this.legendMaxWidth, - numericLegendData: this.numericLegendData, - numericBinSize: this.numericBinSize, - numericBinStroke: this.numericBinStroke, - numericBinStrokeWidth: this.numericBinStrokeWidth, + numericBins: this.numericLegendData, + binSize: this.numericBinSize, equalSizeBins: this.equalSizeBins, - legendTitle: this.legendTitle, - numericLegendY: this.numericLegendY, - legendTextColor: this.legendTextColor, - legendTickSize: this.legendTickSize, + title: this.legendTitle, + y: this.numericLegendY, + tickSize: this.legendTickSize, } } @@ -1488,8 +1488,8 @@ export class LineChart } @computed get externalLegend(): - | (HorizontalNumericColorLegendProps & - HorizontalCategoricalColorLegendProps) + | HorizontalCategoricalColorLegendProps + | HorizontalNumericColorLegendProps | undefined { if (!this.manager.showLegend) { const numericLegendData = this.hasColorScale @@ -1507,15 +1507,12 @@ export class LineChart }) ) return { - legendTitle: this.legendTitle, - legendTextColor: this.legendTextColor, - legendTickSize: this.legendTickSize, + categoricalBins: categoricalLegendData, + numericBins: numericLegendData, + title: this.legendTitle, + tickSize: this.legendTickSize, equalSizeBins: this.equalSizeBins, - numericBinSize: this.numericBinSize, - numericBinStroke: this.numericBinStroke, - numericBinStrokeWidth: this.numericBinStrokeWidth, - numericLegendData, - categoricalLegendData, + binSize: this.numericBinSize, } } return undefined diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 469f54f59ec..077da8ccef6 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -539,10 +539,8 @@ export class MapChart ? new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, - categoryLegendY: this.categoryLegendY, maxWidth: this.legendMaxWidth, - categoricalLegendData: this.categoricalLegendData, - categoricalBinStroke: this.categoricalBinStroke, + categoricalBins: this.categoricalLegendData, }) : undefined } @@ -555,10 +553,9 @@ export class MapChart fontSize: this.fontSize, x: this.legendX, align: this.legendAlign, - numericLegendY: this.numericLegendY, + y: this.numericLegendY, maxWidth: this.legendMaxWidth, - numericLegendData: this.numericLegendData, - numericFocusBracket: this.numericFocusBracket, + numericBins: this.numericLegendData, equalSizeBins: this.equalSizeBins, }) : undefined @@ -570,7 +567,7 @@ export class MapChart fontSize: this.fontSize, align: this.legendAlign, maxWidth: this.legendMaxWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) + 5 : 0 } @@ -580,7 +577,7 @@ export class MapChart ? HorizontalCategoricalColorLegend.numLines({ fontSize: this.fontSize, maxWidth: this.legendMaxWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) : 0 } @@ -592,7 +589,7 @@ export class MapChart x: this.legendX, align: this.legendAlign, maxWidth: this.legendMaxWidth, - numericLegendData: this.numericLegendData, + numericBins: this.numericLegendData, equalSizeBins: this.equalSizeBins, }) : 0 @@ -646,16 +643,19 @@ export class MapChart {this.numericLegend && ( )} {this.categoryLegend && ( )} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 70689fb391c..faa213382fd 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -604,7 +604,7 @@ export class SlopeChart color: series.color, }) ) - return { categoricalLegendData } + return { categoricalBins: categoricalLegendData } } return undefined } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index 8e2d0e19467..db1930a023b 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -451,7 +451,7 @@ export class AbstractStackedChart }) ) .reverse() - return { categoricalLegendData } + return { categoricalBins: categoricalLegendData } } return undefined } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index ebda9d03224..5bf6bdbf524 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -900,7 +900,7 @@ export class MarimekkoChart fontSize: this.fontSize, align: this.legendAlign, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -908,9 +908,8 @@ export class MarimekkoChart return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, - categoryLegendY: this.categoryLegendY, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -1069,9 +1068,10 @@ export class MarimekkoChart {this.renderBars()} {target && ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts index 7f7dfd0bd02..97248d61bb5 100755 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.test.ts @@ -232,6 +232,6 @@ describe("externalLegendBins", () => { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart.externalLegend?.categoricalLegendData?.length).toEqual(2) + expect(chart.externalLegend?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index bb7c4085e33..cc80771e2b3 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -331,7 +331,7 @@ export class StackedBarChart fontSize: this.fontSize, align: this.legendAlign, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -339,9 +339,8 @@ export class StackedBarChart return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, - categoryLegendY: this.categoryLegendY, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -503,9 +502,10 @@ export class StackedBarChart ) : ( { }) expect(chart["legendHeight"]).toEqual(0) expect(chart["categoricalLegendData"].length).toEqual(0) - expect(chart["externalLegend"]?.categoricalLegendData?.length).toEqual( - 2 - ) + expect(chart["externalLegend"]?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index 118dc1ce50e..a7ed53fd54c 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -531,7 +531,7 @@ export class StackedDiscreteBarChart | undefined { if (!this.showLegend) { return { - categoricalLegendData: this.legendBins, + categoricalBins: this.legendBins, } } return undefined @@ -553,9 +553,8 @@ export class StackedDiscreteBarChart return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, - categoryLegendY: this.categoryLegendY, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -564,7 +563,7 @@ export class StackedDiscreteBarChart fontSize: this.fontSize, align: this.legendAlign, maxWidth: this.legendWidth, - categoricalLegendData: this.categoricalLegendData, + categoricalBins: this.categoricalLegendData, }) } @@ -746,8 +745,9 @@ export class StackedDiscreteBarChart ) } From ce497de0f9ec490a84a7da5a0965b7358a1fc195 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 11:43:37 +0100 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20categorical=20l?= =?UTF-8?q?egend=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HorizontalCategoricalColorLegend.ts | 2 +- ...izontalCategoricalColorLegendComponent.tsx | 241 +++++++++--------- 2 files changed, 117 insertions(+), 126 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts index 89ec6f9b000..68683239747 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -14,7 +14,7 @@ export interface HorizontalCategoricalColorLegendProps { fontSize?: number } -interface CategoricalMark { +export interface CategoricalMark { x: number y: number width: number diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx index ee0f4b0228a..ef6d6a13fd0 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -1,5 +1,8 @@ import React from "react" -import { HorizontalCategoricalColorLegend } from "./HorizontalCategoricalColorLegend" +import { + CategoricalMark, + HorizontalCategoricalColorLegend, +} from "./HorizontalCategoricalColorLegend" import { Color, dyFromAlign, @@ -53,82 +56,91 @@ export function HorizontalCategoricalColorLegendComponent({ id={makeIdForHumanConsumption("categorical-color-legend")} className="categoricalColorLegend" > - - + + {legend.marks.map((mark, index) => ( + + ))} + + + {legend.marks.map((mark, index) => ( + + {isInteractive && ( - + + {legend.marks.map((mark, index) => ( + + ))} + )} ) } -function Labels({ +function Label({ + mark, x, y, - legend, focusColors, hoverColors = [], }: { + mark: CategoricalMark x: number y: number - legend: HorizontalCategoricalColorLegend focusColors?: string[] // focused colors are bolded hoverColors?: string[] // non-hovered colors are muted }): React.ReactElement { - const { marks } = legend + const isFocus = focusColors?.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && !hoverColors.includes(mark.bin.color) return ( - - {marks.map((mark, index) => { - const isFocus = focusColors?.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - return ( - - {mark.label.text} - - ) - })} - + + {mark.label.text} + ) } -function Swatches({ +function Swatch({ legend, + mark, x, y, activeColors, @@ -136,6 +148,7 @@ function Swatches({ opacity, swatchStrokeColor, }: { + mark: CategoricalMark legend: HorizontalCategoricalColorLegend x: number y: number @@ -144,47 +157,38 @@ function Swatches({ opacity?: number swatchStrokeColor?: Color }): React.ReactElement { - const { marks } = legend + const isActive = activeColors?.includes(mark.bin.color) + const isHovered = hoverColors.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && !hoverColors.includes(mark.bin.color) + + const color = mark.bin.patternRef + ? `url(#${mark.bin.patternRef})` + : mark.bin.color + + const fill = + isHovered || isActive || activeColors === undefined + ? color + : OWID_NON_FOCUSED_GRAY return ( - - {marks.map((mark, index) => { - const isActive = activeColors?.includes(mark.bin.color) - const isHovered = hoverColors.includes(mark.bin.color) - const isNotHovered = - hoverColors.length > 0 && - !hoverColors.includes(mark.bin.color) - - const color = mark.bin.patternRef - ? `url(#${mark.bin.patternRef})` - : mark.bin.color - - const fill = - isHovered || isActive || activeColors === undefined - ? color - : OWID_NON_FOCUSED_GRAY - - return ( - - ) - })} - + ) } -function InteractiveElements({ +function InteractiveElement({ legend, + mark, x, y, onClick, @@ -192,49 +196,36 @@ function InteractiveElements({ onMouseLeave, }: { legend: HorizontalCategoricalColorLegend + mark: CategoricalMark x: number y: number onMouseLeave?: () => void onMouseOver?: (d: ColorScaleBin) => void onClick?: (d: ColorScaleBin) => void }): React.ReactElement { - const { marks } = legend + const mouseOver = (): void => + onMouseOver ? onMouseOver(mark.bin) : undefined + const mouseLeave = (): void => (onMouseLeave ? onMouseLeave() : undefined) + const click = onClick ? (): void => onClick?.(mark.bin) : undefined + + const cursor = click ? "pointer" : "default" return ( - - {marks.map((mark, index) => { - const mouseOver = (): void => - onMouseOver ? onMouseOver(mark.bin) : undefined - const mouseLeave = (): void => - onMouseLeave ? onMouseLeave() : undefined - const click = onClick - ? (): void => onClick?.(mark.bin) - : undefined - - const cursor = click ? "pointer" : "default" - - return ( - - {/* for hover interaction */} - - - ) - })} + + {/* for hover interaction */} + ) } From 8adf27af8abaac25e68d6ecb45c535b3c89a5014 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Sun, 22 Dec 2024 11:51:52 +0100 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=94=A8=20introduce=20external=20leg?= =?UTF-8?q?end=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/chart/ChartInterface.ts | 8 +++++--- .../grapher/src/facetChart/FacetChart.tsx | 5 +---- .../grapher/src/lineCharts/LineChart.test.ts | 2 +- .../grapher/src/lineCharts/LineChart.tsx | 8 ++------ .../grapher/src/slopeCharts/SlopeChart.tsx | 6 ++---- .../src/stackedCharts/AbstractStackedChart.tsx | 7 ++----- .../src/stackedCharts/StackedDiscreteBarChart.tsx | 11 +++-------- 7 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts index b22df8550e8..41fb9091ebf 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts @@ -21,6 +21,10 @@ export interface ChartSeries { export type ChartTableTransformer = (inputTable: OwidTable) => OwidTable +export type ExternalLegendProps = + Partial & + Partial + export interface ChartInterface { failMessage: string // We require every chart have some fail message(s) to show to the user if something went wrong @@ -45,9 +49,7 @@ export interface ChartInterface { * The legend that has been hidden from the chart plot (using `manager.hideLegend`). * Used to create a global categorical legend for faceted charts. */ - externalLegend?: - | HorizontalCategoricalColorLegendProps - | HorizontalNumericColorLegendProps + externalLegend?: ExternalLegendProps /** * Which facet strategies the chart type finds reasonable in its current setting, if any. diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 373f5ebae31..113e591038d 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -36,7 +36,7 @@ import { DefaultChartClass, } from "../chart/ChartTypeMap" import { ChartManager } from "../chart/ChartManager" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { getChartPadding, getFontSize, @@ -74,9 +74,6 @@ const SHARED_X_AXIS_MIN_FACET_COUNT = 12 const facetBackgroundColor = "none" // we don't use color yet but may use it for background later -type ExternalLegendProps = Partial & - Partial - const getContentBounds = ( containerBounds: Bounds, manager: ChartManager, diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index 169e5989f01..3ad36385de6 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -300,7 +300,7 @@ describe("externalLegendBins", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart.externalLegend?.bins?.length).toEqual(2) + expect(chart.externalLegend?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 346a87f85cf..232fcffedfd 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -63,7 +63,7 @@ import { } from "../core/GrapherConstants" import { ColorSchemes } from "../color/ColorSchemes" import { AxisConfig, AxisManager } from "../axis/AxisConfig" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { LinesProps, LineChartSeries, @@ -115,7 +115,6 @@ import { HorizontalNumericColorLegend, HorizontalNumericColorLegendProps, } from "../horizontalColorLegend/HorizontalNumericColorLegend" -import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const LINE_CHART_CLASS_NAME = "LineChart" @@ -1487,10 +1486,7 @@ export class LineChart return this.dualAxis.horizontalAxis } - @computed get externalLegend(): - | HorizontalCategoricalColorLegendProps - | HorizontalNumericColorLegendProps - | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const numericLegendData = this.hasColorScale ? this.numericLegendData diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index faa213382fd..7b012437a4e 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -37,7 +37,7 @@ import { InteractionState, HorizontalAlign, } from "@ourworldindata/types" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" import { scaleLinear, ScaleLinear } from "d3-scale" import { select } from "d3-selection" @@ -591,9 +591,7 @@ export class SlopeChart : 0 } - @computed get externalLegend(): - | HorizontalCategoricalColorLegendProps - | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series.map( (series, index) => diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index db1930a023b..127ebc369d0 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -1,6 +1,6 @@ import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" import { AxisConfig, AxisManager } from "../axis/AxisConfig" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" import { ColorSchemeName, @@ -43,7 +43,6 @@ import { CategoricalColorMap, } from "../color/CategoricalColorAssigner.js" import { BinaryMapPaletteE } from "../color/CustomSchemes" -import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" // used in StackedBar charts to color negative and positive bars const POSITIVE_COLOR = BinaryMapPaletteE.colorSets[0][0] // orange @@ -436,9 +435,7 @@ export class AbstractStackedChart return this.unstackedSeries } - @computed get externalLegend(): - | HorizontalCategoricalColorLegendProps - | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series .map( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index a7ed53fd54c..87e0dcc518a 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -44,7 +44,7 @@ import { } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" import { AxisConfig } from "../axis/AxisConfig" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { OwidTable, CoreColumn } from "@ourworldindata/core-table" import { autoDetectYColumnSlugs, @@ -76,10 +76,7 @@ import { easeQuadOut } from "d3-ease" import { bind } from "decko" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner.js" import { TextWrap } from "@ourworldindata/components" -import { - HorizontalCategoricalColorLegend, - HorizontalCategoricalColorLegendProps, -} from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { HorizontalCategoricalColorLegend } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" // if an entity name exceeds this width, we use the short name instead (if available) @@ -526,9 +523,7 @@ export class StackedDiscreteBarChart return this.showLegend ? this.legendBins : [] } - @computed get externalLegend(): - | HorizontalCategoricalColorLegendProps - | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.showLegend) { return { categoricalBins: this.legendBins, From 571b46cc19461542d91c45f615e033dddce08ce0 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Thu, 2 Jan 2025 15:14:19 +0100 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=94=A8=20fix=20eslint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 7b012437a4e..800c2676552 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -93,7 +93,6 @@ import { GRAPHER_DARK_TEXT, } from "../color/ColorConstants" import { FocusArray } from "../focus/FocusArray" -import { HorizontalCategoricalColorLegendProps } from "../horizontalColorLegend/HorizontalCategoricalColorLegend" type SVGMouseOrTouchEvent = | React.MouseEvent From 5576c7f8f6defa91c007a3926cbf3a290c925a86 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 14:41:31 +0100 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20numeric=20legen?= =?UTF-8?q?d=20in=20discrete=20bar=20chart=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/barCharts/DiscreteBarChart.tsx | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index f590f248d8f..c0060dcad30 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -41,7 +41,7 @@ import { HorizontalAxisZeroLine } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" import { AxisConfig, AxisManager } from "../axis/AxisConfig" import { ColorSchemes } from "../color/ColorSchemes" -import { ChartInterface } from "../chart/ChartInterface" +import { ChartInterface, ExternalLegendProps } from "../chart/ChartInterface" import { BACKGROUND_COLOR, DiscreteBarChartManager, @@ -72,10 +72,7 @@ import { import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { BaseType, Selection } from "d3" import { TextWrap } from "@ourworldindata/components" -import { - HorizontalNumericColorLegend, - HorizontalNumericColorLegendProps, -} from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegend } from "../horizontalColorLegend/HorizontalNumericColorLegend" import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const labelToTextPadding = 10 @@ -171,7 +168,7 @@ export class DiscreteBarChart @computed private get boundsWithoutColorLegend(): Bounds { return this.bounds.padTop( - this.showColorLegend ? this.legendHeight + LEGEND_PADDING : 0 + this.numericLegend ? this.legendHeight + LEGEND_PADDING : 0 ) } @@ -505,9 +502,9 @@ export class DiscreteBarChart return ( <> {this.renderDefs()} - {this.showColorLegend && ( + {this.numericLegend && ( @@ -821,26 +818,6 @@ export class DiscreteBarChart return sortBy(legendBins, (bin) => bin instanceof CategoricalBin) } - @computed - private get legendProps(): HorizontalNumericColorLegendProps { - return { - fontSize: this.fontSize, - x: this.legendX, - align: this.legendAlign, - maxWidth: this.legendMaxWidth, - numericBins: this.numericLegendData, - binSize: this.numericBinSize, - equalSizeBins: this.equalSizeBins, - title: this.legendTitle, - y: this.numericLegendY, - tickSize: this.legendTickSize, - } - } - - @computed private get legend(): HorizontalNumericColorLegend { - return new HorizontalNumericColorLegend(this.legendProps) - } - @computed get projectedDataColorInLegend(): string { // if a single color is in use, use that color in the legend if (uniqBy(this.series, "color").length === 1) @@ -848,9 +825,7 @@ export class DiscreteBarChart return DEFAULT_PROJECTED_DATA_COLOR_IN_LEGEND } - @computed get externalNumericLegend(): - | HorizontalNumericColorLegendProps - | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (this.hasColorLegend) { return { numericBins: this.numericLegendData, @@ -870,10 +845,23 @@ export class DiscreteBarChart legendTextColor = "#555" legendTickSize = 1 - @computed get legendHeight(): number { - return this.hasColorScale && this.manager.showLegend - ? HorizontalNumericColorLegend.height(this.legendProps) - : 0 + @computed private get numericLegend(): + | HorizontalNumericColorLegend + | undefined { + return this.showColorLegend + ? new HorizontalNumericColorLegend({ + fontSize: this.fontSize, + x: this.legendX, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, + numericBins: this.numericLegendData, + binSize: this.numericBinSize, + equalSizeBins: this.equalSizeBins, + title: this.legendTitle, + y: this.numericLegendY, + tickSize: this.legendTickSize, + }) + : undefined } @computed get numericLegendY(): number { @@ -886,6 +874,10 @@ export class DiscreteBarChart : undefined } + @computed get legendHeight(): number { + return this.numericLegend?.height ?? 0 + } + // End of color legend props @computed get series(): DiscreteBarSeries[] { From b70a055af6b414540d90aa504465baf8d8615182 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 14:50:31 +0100 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20legend=20in=20l?= =?UTF-8?q?ine=20chart=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/chart/ChartInterface.ts | 2 +- .../grapher/src/lineCharts/LineChart.tsx | 47 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts index 41fb9091ebf..923388af6b5 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartInterface.ts @@ -47,7 +47,7 @@ export interface ChartInterface { /** * The legend that has been hidden from the chart plot (using `manager.hideLegend`). - * Used to create a global categorical legend for faceted charts. + * Used to create a global legend for faceted charts. */ externalLegend?: ExternalLegendProps diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 232fcffedfd..0024676efd6 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -111,10 +111,7 @@ import { getSeriesName, } from "./LineChartHelpers" import { FocusArray } from "../focus/FocusArray.js" -import { - HorizontalNumericColorLegend, - HorizontalNumericColorLegendProps, -} from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegend } from "../horizontalColorLegend/HorizontalNumericColorLegend" import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const LINE_CHART_CLASS_NAME = "LineChart" @@ -927,7 +924,7 @@ export class LineChart } renderColorLegend(): React.ReactElement | void { - if (this.hasColorLegend) + if (this.colorLegend) return ( Date: Fri, 3 Jan 2025 14:54:51 +0100 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20legend=20in=20m?= =?UTF-8?q?arimekko=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/stackedCharts/MarimekkoChart.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index 5bf6bdbf524..c82b789795c 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -549,7 +549,7 @@ export class MarimekkoChart return this.bounds .padBottom(this.longestLabelHeight + 2) .padBottom(labelLinesHeight) - .padTop(this.legendHeight + this.legendPaddingTop) + .padTop(this.legend.height + this.legendPaddingTop) .padLeft(marginToEnsureWidestEntityLabelFitsEvenIfAtX0) } @@ -836,7 +836,7 @@ export class MarimekkoChart // legend props @computed get legendPaddingTop(): number { - return this.legendHeight > 0 ? this.baseFontSize : 0 + return this.legend.height > 0 ? this.baseFontSize : 0 } @computed get legendX(): number { @@ -895,16 +895,7 @@ export class MarimekkoChart this.focusColorBin = undefined } - @computed private get legendHeight(): number { - return HorizontalCategoricalColorLegend.height({ - fontSize: this.fontSize, - align: this.legendAlign, - maxWidth: this.legendWidth, - categoricalBins: this.categoricalLegendData, - }) - } - - @computed get legend(): HorizontalCategoricalColorLegend { + @computed private get legend(): HorizontalCategoricalColorLegend { return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, From 8e5a627bfccfcace262b0698660fbed2f61ded46 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 15:09:40 +0100 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20legend=20in=20s?= =?UTF-8?q?tacked=20chart=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/stackedCharts/StackedBarChart.tsx | 15 +++------------ .../src/stackedCharts/StackedDiscreteBarChart.tsx | 13 ++----------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index cc80771e2b3..cf666440a2d 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -212,7 +212,7 @@ export class StackedBarChart @computed protected get paddingForLegendTop(): number { return this.showHorizontalLegend - ? this.horizontalColorLegendHeight + 8 + ? this.horizontalColorLegend.height + 8 : 0 } @@ -326,16 +326,7 @@ export class StackedBarChart } @computed - private get horizontalColorLegendHeight(): number { - return HorizontalCategoricalColorLegend.height({ - fontSize: this.fontSize, - align: this.legendAlign, - maxWidth: this.legendWidth, - categoricalBins: this.categoricalLegendData, - }) - } - - @computed get colorLegend(): HorizontalCategoricalColorLegend { + private get horizontalColorLegend(): HorizontalCategoricalColorLegend { return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, @@ -500,7 +491,7 @@ export class StackedBarChart return showHorizontalLegend ? ( 0 - ? this.legendHeight + this.legendPaddingTop + this.showLegend && this.legend.height > 0 + ? this.legend.height + this.legendPaddingTop : 0 ) } @@ -553,15 +553,6 @@ export class StackedDiscreteBarChart }) } - @computed private get legendHeight(): number { - return HorizontalCategoricalColorLegend.height({ - fontSize: this.fontSize, - align: this.legendAlign, - maxWidth: this.legendWidth, - categoricalBins: this.categoricalLegendData, - }) - } - @computed private get formatColumn(): CoreColumn { return this.yColumns[0] } From 492a7de7bad46371b358f960d8cf66bb4339fb5c Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 15:10:58 +0100 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=94=A8=20simplify=20legend=20in=20m?= =?UTF-8?q?ap=20chart=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HorizontalCategoricalColorLegend.ts | 5 --- .../grapher/src/mapCharts/MapChart.tsx | 43 ++++++++----------- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts index 68683239747..4e235b62da3 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -43,11 +43,6 @@ export class HorizontalCategoricalColorLegend { this.props = props } - static height(props: HorizontalCategoricalColorLegendProps): number { - const legend = new HorizontalCategoricalColorLegend(props) - return legend.height - } - static numLines(props: HorizontalCategoricalColorLegendProps): number { const legend = new HorizontalCategoricalColorLegend(props) return legend.numLines diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 077da8ccef6..9a350623a8f 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -532,6 +532,24 @@ export class MapChart return this.numericLegendData.length > 1 } + @computed get numericLegendHeight(): number { + // can't use numericLegend due to a circular dependency + return this.hasNumericLegend + ? HorizontalNumericColorLegend.height({ + fontSize: this.fontSize, + x: this.legendX, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, + numericBins: this.numericLegendData, + equalSizeBins: this.equalSizeBins, + }) + : 0 + } + + @computed get categoryLegendHeight(): number { + return this.categoryLegend ? this.categoryLegend.height + 5 : 0 + } + @computed private get categoryLegend(): | HorizontalCategoricalColorLegend | undefined { @@ -561,18 +579,8 @@ export class MapChart : undefined } - @computed get categoryLegendHeight(): number { - return this.hasCategoryLegend - ? HorizontalCategoricalColorLegend.height({ - fontSize: this.fontSize, - align: this.legendAlign, - maxWidth: this.legendMaxWidth, - categoricalBins: this.categoricalLegendData, - }) + 5 - : 0 - } - @computed get categoryLegendNumLines(): number { + // can't use categoryLegend due to a circular dependency return this.hasCategoryLegend ? HorizontalCategoricalColorLegend.numLines({ fontSize: this.fontSize, @@ -582,19 +590,6 @@ export class MapChart : 0 } - @computed get numericLegendHeight(): number { - return this.hasNumericLegend - ? HorizontalNumericColorLegend.height({ - fontSize: this.fontSize, - x: this.legendX, - align: this.legendAlign, - maxWidth: this.legendMaxWidth, - numericBins: this.numericLegendData, - equalSizeBins: this.equalSizeBins, - }) - : 0 - } - @computed get categoryLegendY(): number { const { hasCategoryLegend, bounds, categoryLegendHeight } = this From 146c57ff04f6a52185c8fb6dc9c62197b16a0273 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 15:30:54 +0100 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=94=A8=20fix=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackedCharts/StackedDiscreteBarChart.test.ts | 12 ++++++------ .../src/stackedCharts/StackedDiscreteBarChart.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts index 7d4ee5dcb27..8dfcdd92411 100755 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts @@ -371,17 +371,17 @@ describe("showLegend", () => { const chart = new StackedDiscreteBarChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["legendHeight"]).toBeGreaterThan(0) - expect(chart["categoricalLegendData"].length).toBeGreaterThan(0) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart.legend.height).toBeGreaterThan(0) + expect(chart.categoricalLegendData.length).toBeGreaterThan(0) + expect(chart.externalLegend).toBeUndefined() }) it("exposes externalLegendBins when showLegend is false", () => { const chart = new StackedDiscreteBarChart({ manager: { ...baseManager, showLegend: false }, }) - expect(chart["legendHeight"]).toEqual(0) - expect(chart["categoricalLegendData"].length).toEqual(0) - expect(chart["externalLegend"]?.categoricalBins?.length).toEqual(2) + expect(chart.legend.height).toEqual(0) + expect(chart.categoricalLegendData.length).toEqual(0) + expect(chart.externalLegend?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index 0d52dd37b00..fc64ac6b2cf 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -544,7 +544,7 @@ export class StackedDiscreteBarChart this.focusSeriesName = undefined } - @computed private get legend(): HorizontalCategoricalColorLegend { + @computed get legend(): HorizontalCategoricalColorLegend { return new HorizontalCategoricalColorLegend({ fontSize: this.fontSize, align: this.legendAlign, From 9f0c4ceb7cdf92dbdf8b3f16f3c5932419fd05bb Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 15:58:39 +0100 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=94=A8=20restore=20swatch=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HorizontalCategoricalColorLegend.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts index 4e235b62da3..35c3cd5a785 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -3,6 +3,7 @@ import { max, Bounds, HorizontalAlign } from "@ourworldindata/utils" import { CategoricalBin } from "../color/ColorScaleBin" import { BASE_FONT_SIZE, + GRAPHER_FONT_SCALE_12, GRAPHER_FONT_SCALE_12_8, } from "../core/GrapherConstants" import { SPACE_BETWEEN_CATEGORICAL_BINS } from "./HorizontalColorLegendConstants" @@ -53,11 +54,15 @@ export class HorizontalCategoricalColorLegend { } @computed private get fontSize(): number { - return GRAPHER_FONT_SCALE_12_8 * (this.props.fontSize ?? BASE_FONT_SIZE) + return this.props.fontSize ?? BASE_FONT_SIZE + } + + @computed private get labelFontSize(): number { + return GRAPHER_FONT_SCALE_12_8 * this.fontSize } @computed get swatchSize(): number { - return this.fontSize * 0.75 + return GRAPHER_FONT_SCALE_12 * this.fontSize } @computed private get align(): HorizontalAlign { @@ -79,7 +84,7 @@ export class HorizontalCategoricalColorLegend { let yOffset = 0 this.visibleBins.forEach((bin) => { const labelBounds = Bounds.forText(bin.text, { - fontSize: this.fontSize, + fontSize: this.labelFontSize, }) const markWidth = this.swatchSize + @@ -106,7 +111,7 @@ export class HorizontalCategoricalColorLegend { x: markX + this.swatchSize + this.swatchMarginRight, y: markY + this.swatchSize / 2, }), - fontSize: this.fontSize, + fontSize: this.labelFontSize, } marks.push({ From 34419621ceebfc78ce3296d8c230f9ff74cccbea Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 3 Jan 2025 16:18:04 +0100 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=94=A8=20only=20render=20interactiv?= =?UTF-8?q?e=20elements=20if=20necessary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/mapCharts/MapChart.tsx | 15 +++++++++++---- .../grapher/src/stackedCharts/MarimekkoChart.tsx | 11 +++++++++-- .../src/stackedCharts/StackedDiscreteBarChart.tsx | 12 ++++++++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 9a350623a8f..37b81c26b9b 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -633,14 +633,21 @@ export class MapChart } renderMapLegend(): React.ReactElement { + const onMouseLeave = this.manager.isStatic + ? undefined + : this.onLegendMouseLeave + const onMouseOver = this.manager.isStatic + ? undefined + : this.onLegendMouseOver + return ( <> {this.numericLegend && ( )} {this.categoryLegend && ( @@ -649,8 +656,8 @@ export class MapChart x={this.legendX} y={this.categoryLegendY} swatchStrokeColor={this.categoricalBinStroke} - onMouseLeave={this.onLegendMouseLeave} - onMouseOver={this.onLegendMouseOver} + onMouseLeave={onMouseLeave} + onMouseOver={onMouseOver} /> )} diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index c82b789795c..21d92aacbd4 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -1030,6 +1030,13 @@ export class MarimekkoChart const footer = excludeUndefined([toleranceNotice, roundingNotice]) + const onLegendMouseLeave = this.manager.isStatic + ? undefined + : this.onLegendMouseLeave + const onLegendMouseOver = this.manager.isStatic + ? undefined + : this.onLegendMouseOver + return ( {this.renderBars()} {target && ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index fc64ac6b2cf..aaf95496be5 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -727,13 +727,21 @@ export class StackedDiscreteBarChart renderLegend(): React.ReactElement | void { if (!this.showLegend) return + + const onMouseLeave = this.manager.isStatic + ? undefined + : this.onLegendMouseLeave + const onMouseOver = this.manager.isStatic + ? undefined + : this.onLegendMouseOver + return ( ) }