diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 4b0857a882a..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, @@ -70,12 +70,10 @@ import { OWID_NO_DATA_GRAY, } from "../color/ColorConstants" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" -import { - HorizontalColorLegendManager, - HorizontalNumericColorLegend, -} from "../horizontalColorLegend/HorizontalColorLegends" import { BaseType, Selection } from "d3" import { TextWrap } from "@ourworldindata/components" +import { HorizontalNumericColorLegend } from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const labelToTextPadding = 10 const labelToBarPadding = 5 @@ -170,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 ) } @@ -504,8 +502,12 @@ export class DiscreteBarChart return ( <> {this.renderDefs()} - {this.showColorLegend && ( - + {this.numericLegend && ( + )} {!this.isLogScale && ( 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 @@ -43,7 +49,7 @@ 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. */ - externalLegend?: HorizontalColorLegendManager + 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 e66b7faa5b1..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, @@ -53,18 +53,22 @@ import { autoDetectYColumnSlugs, makeSelectionArray } from "../chart/ChartUtils" import { SelectionArray } from "../selection/SelectionArray" import { AxisConfig } from "../axis/AxisConfig" import { HorizontalAxis, VerticalAxis } from "../axis/Axis" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegend, - HorizontalColorLegendManager, - HorizontalNumericColorLegend, -} from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin, NumericBin, } from "../color/ColorScaleBin" import { GRAPHER_DARK_TEXT } from "../color/ColorConstants" +import { + HorizontalCategoricalColorLegend, + HorizontalCategoricalColorLegendProps, +} from "../horizontalColorLegend/HorizontalCategoricalColorLegend" +import { + HorizontalNumericColorLegend, + HorizontalNumericColorLegendProps, +} from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" +import { HorizontalCategoricalColorLegendComponent } from "../horizontalColorLegend/HorizontalCategoricalColorLegendComponent" const SHARED_X_AXIS_MIN_FACET_COUNT = 12 @@ -118,7 +122,7 @@ interface AxesInfo { @observer export class FacetChart extends React.Component - implements ChartInterface, HorizontalColorLegendManager + implements ChartInterface { transformTable(table: OwidTable): OwidTable { return table @@ -589,7 +593,8 @@ export class FacetChart // legend utils - @computed private get externalLegends(): HorizontalColorLegendManager[] { + @computed + private get externalLegends(): ExternalLegendProps[] { return excludeUndefined( this.intermediateChartInstances.map( (instance) => instance.externalLegend @@ -599,18 +604,10 @@ 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) ) } - @computed private get LegendClass(): - | typeof HorizontalNumericColorLegend - | typeof HorizontalCategoricalColorLegend { - return this.isNumericLegend - ? HorizontalNumericColorLegend - : HorizontalCategoricalColorLegend - } - @computed private get showLegend(): boolean { const { isNumericLegend, categoricalLegendData, numericLegendData } = this @@ -641,9 +638,9 @@ export class FacetChart return false } - private getExternalLegendProp< - Prop extends keyof HorizontalColorLegendManager, - >(prop: Prop): HorizontalColorLegendManager[Prop] | undefined { + private getExternalLegendProp( + prop: Prop + ): ExternalLegendProps[Prop] | undefined { for (const externalLegend of this.externalLegends) { if (externalLegend[prop] !== undefined) { return externalLegend[prop] @@ -667,64 +664,35 @@ 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(): ExternalLegendProps { + return { + fontSize: this.fontSize, + x: this.bounds.x, + maxWidth: this.bounds.width, + align: HorizontalAlign.left, + } } - @computed get numericBinStrokeWidth(): number | undefined { - return this.getExternalLegendProp("numericBinStrokeWidth") + @computed + private get numericLegendProps(): HorizontalNumericColorLegendProps { + return { + ...this.commonLegendProps, + y: this.bounds.top, + title: this.getExternalLegendProp("title"), + tickSize: this.getExternalLegendProp("tickSize"), + binSize: this.getExternalLegendProp("binSize"), + equalSizeBins: this.getExternalLegendProp("equalSizeBins"), + numericBins: this.numericLegendData, + } } - @computed get equalSizeBins(): boolean | undefined { - return this.getExternalLegendProp("equalSizeBins") + @computed + private get categoricalLegendProps(): HorizontalCategoricalColorLegendProps { + return { + ...this.commonLegendProps, + categoricalBins: this.categoricalLegendData, + } } @computed get hoverColors(): Color[] | undefined { @@ -752,8 +720,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) @@ -768,8 +736,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) @@ -814,8 +782,18 @@ export class FacetChart // end of legend props - @computed private get legend(): HorizontalColorLegend { - return new this.LegendClass({ manager: this }) + @computed private get categoryLegend(): HorizontalCategoricalColorLegend { + return new HorizontalCategoricalColorLegend(this.categoricalLegendProps) + } + + @computed private get numericLegend(): HorizontalNumericColorLegend { + return new HorizontalNumericColorLegend(this.numericLegendProps) + } + + @computed private get legend(): + | HorizontalNumericColorLegend + | HorizontalCategoricalColorLegend { + return this.isNumericLegend ? this.numericLegend : this.categoryLegend } @computed private get isFocusModeSupported(): boolean { @@ -866,11 +844,33 @@ 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/HorizontalCategoricalColorLegend.ts b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts new file mode 100644 index 00000000000..35c3cd5a785 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegend.ts @@ -0,0 +1,177 @@ +import { computed } from "mobx" +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" + +export interface HorizontalCategoricalColorLegendProps { + categoricalBins: CategoricalBin[] + maxWidth?: number + align?: HorizontalAlign + fontSize?: number +} + +export interface CategoricalMark { + x: number + y: number + width: number + label: { + text: string + bounds: Bounds + fontSize: number + } + bin: CategoricalBin +} + +interface MarkLine { + totalWidth: number + marks: CategoricalMark[] +} + +export class HorizontalCategoricalColorLegend { + /** Margin between the swatch and the label */ + swatchMarginRight = 5 + + /** Horizontal space between two bins */ + horizontalBinMargin = 5 + + props: HorizontalCategoricalColorLegendProps + constructor(props: HorizontalCategoricalColorLegendProps) { + this.props = props + } + + static numLines(props: HorizontalCategoricalColorLegendProps): number { + const legend = new HorizontalCategoricalColorLegend(props) + return legend.numLines + } + + @computed private get maxWidth(): number { + return this.props.maxWidth ?? 200 + } + + @computed private get fontSize(): number { + 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 GRAPHER_FONT_SCALE_12 * this.fontSize + } + + @computed private get align(): HorizontalAlign { + return this.props.align ?? HorizontalAlign.center + } + + @computed private get bins(): CategoricalBin[] { + return this.props.categoricalBins ?? [] + } + + @computed private get visibleBins(): CategoricalBin[] { + return this.bins.filter((bin) => !bin.isHidden) + } + + @computed private get markLines(): MarkLine[] { + const lines: MarkLine[] = [] + let marks: CategoricalMark[] = [] + let xOffset = 0 + let yOffset = 0 + this.visibleBins.forEach((bin) => { + const labelBounds = Bounds.forText(bin.text, { + fontSize: this.labelFontSize, + }) + const markWidth = + this.swatchSize + + this.swatchMarginRight + + labelBounds.width + + this.horizontalBinMargin + + if (xOffset + markWidth > this.maxWidth && marks.length > 0) { + lines.push({ + totalWidth: xOffset - this.horizontalBinMargin, + marks: marks, + }) + marks = [] + xOffset = 0 + yOffset += this.swatchSize + this.swatchMarginRight + } + + const markX = xOffset + const markY = yOffset + + const label = { + text: bin.text, + bounds: labelBounds.set({ + x: markX + this.swatchSize + this.swatchMarginRight, + y: markY + this.swatchSize / 2, + }), + fontSize: this.labelFontSize, + } + + marks.push({ + x: markX, + y: markY, + width: markWidth, + label, + bin, + }) + + xOffset += markWidth + SPACE_BETWEEN_CATEGORICAL_BINS + }) + + if (marks.length > 0) + lines.push({ + totalWidth: xOffset - this.horizontalBinMargin, + 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.maxWidth ?? this.contentWidth + } + + @computed get marks(): CategoricalMark[] { + const lines = this.markLines + const align = this.align + 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 + this.swatchSize)) ?? 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..ef6d6a13fd0 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalCategoricalColorLegendComponent.tsx @@ -0,0 +1,231 @@ +import React from "react" +import { + CategoricalMark, + HorizontalCategoricalColorLegend, +} from "./HorizontalCategoricalColorLegend" +import { + Color, + 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" + +interface HorizontalCategoricalColorLegendProps { + legend: HorizontalCategoricalColorLegend + + // positioning + x?: number + 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, + x = 0, + y = 0, + opacity, + swatchStrokeColor, + focusColors, + hoverColors, + activeColors, + onClick, + onMouseOver, + onMouseLeave, +}: HorizontalCategoricalColorLegendProps): React.ReactElement { + const isInteractive = onClick || onMouseOver || onMouseLeave + + return ( + + + {legend.marks.map((mark, index) => ( + + ))} + + + {legend.marks.map((mark, index) => ( + + + {isInteractive && ( + + {legend.marks.map((mark, index) => ( + + ))} + + )} + + ) +} + +function Label({ + mark, + x, + y, + focusColors, + hoverColors = [], +}: { + mark: CategoricalMark + x: number + y: number + focusColors?: string[] // focused colors are bolded + hoverColors?: string[] // non-hovered colors are muted +}): React.ReactElement { + const isFocus = focusColors?.includes(mark.bin.color) + const isNotHovered = + hoverColors.length > 0 && !hoverColors.includes(mark.bin.color) + + return ( + + {mark.label.text} + + ) +} + +function Swatch({ + legend, + mark, + x, + y, + activeColors, + hoverColors = [], + opacity, + swatchStrokeColor, +}: { + mark: CategoricalMark + legend: HorizontalCategoricalColorLegend + x: number + y: number + activeColors?: string[] // inactive colors are grayed out + hoverColors?: string[] // non-hovered colors are muted + opacity?: number + swatchStrokeColor?: Color +}): React.ReactElement { + 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 InteractiveElement({ + legend, + mark, + x, + y, + onClick, + onMouseOver, + onMouseLeave, +}: { + legend: HorizontalCategoricalColorLegend + mark: CategoricalMark + x: number + y: number + onMouseLeave?: () => void + onMouseOver?: (d: ColorScaleBin) => void + onClick?: (d: ColorScaleBin) => void +}): React.ReactElement { + 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 */} + + + ) +} 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 bc38e848369..1682e46fff7 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", () => { @@ -21,55 +21,53 @@ describe(HorizontalNumericColorLegend, () => { }) const legend = new HorizontalNumericColorLegend({ - manager: { numericLegendData: [bin] }, + numericBins: [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", - }), - ], - }, + numericBins: [ + 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] }, + categoricalBins: [bin], }) expect(legend.height).toBeGreaterThan(0) }) diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx deleted file mode 100644 index 37ed0325865..00000000000 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx +++ /dev/null @@ -1,956 +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[] -} - -// 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 { - 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 - onLegendMouseLeave?: () => void - onLegendMouseOver?: (d: ColorScaleBin) => void - onLegendClick?: (d: ColorScaleBin) => void - activeColors?: string[] // inactive colors are grayed out - focusColors?: string[] // focused colors are bolded - hoverColors?: string[] // non-hovered colors are muted - isStatic?: boolean -} - -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 extends React.Component<{ - manager: HorizontalColorLegendManager -}> { - @computed protected get manager(): HorizontalColorLegendManager { - return this.props.manager - } - - @computed protected get legendX(): number { - return this.manager.legendX ?? 0 - } - - @computed protected get categoryLegendY(): number { - return this.manager.categoryLegendY ?? 0 - } - - @computed protected get numericLegendY(): number { - return this.manager.numericLegendY ?? 0 - } - - @computed protected get legendMaxWidth(): number | undefined { - return this.manager.legendMaxWidth - } - - @computed protected get legendHeight(): number { - return this.manager.legendHeight ?? 200 - } - - @computed protected get legendAlign(): HorizontalAlign { - // Assume center alignment if none specified, for backwards-compatibility - return this.manager.legendAlign ?? HorizontalAlign.center - } - - @computed protected get fontSize(): number { - return this.manager.fontSize ?? BASE_FONT_SIZE - } - - @computed protected get legendTextColor(): Color { - return this.manager.legendTextColor ?? DEFAULT_TEXT_COLOR - } - - @computed protected get legendTickSize(): number { - return this.manager.legendTickSize ?? DEFAULT_TICK_SIZE - } - - abstract get height(): number - abstract get width(): number -} - -@observer -export class HorizontalNumericColorLegend extends HorizontalColorLegend { - base: React.RefObject = React.createRef() - - @computed private get numericLegendData(): ColorScaleBin[] { - return this.manager.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.manager.numericBinSize ?? DEFAULT_NUMERIC_BIN_SIZE - } - - @computed private get numericBinStroke(): Color { - return this.props.manager.numericBinStroke ?? DEFAULT_NUMERIC_BIN_STROKE - } - - @computed private get numericBinStrokeWidth(): number { - return ( - this.props.manager.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.manager.legendMaxWidth ?? this.manager.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.manager.legendWidth === undefined && - this.manager.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.manager.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 { - manager, - rangeSize, - availableNumericWidth, - visibleBins, - numericBins, - legendTitleWidth, - x, - } = this - - 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 (manager.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.manager - 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 { manager, base, positionedBins } = this - const { numericFocusBracket } = manager - 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 && manager.onLegendMouseLeave) - return manager.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 && manager.onLegendMouseOver) - manager.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 { - manager, - numericLabels, - numericBinSize, - positionedBins, - height, - } = this - const { numericFocusBracket } = manager - - 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 - - @computed get width(): number { - return this.manager.legendWidth ?? this.manager.legendMaxWidth ?? 200 - } - - @computed private get categoricalLegendData(): CategoricalBin[] { - return this.manager.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 { manager, marks } = this - const { focusColors, hoverColors = [] } = manager - - 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 { manager, marks } = this - const { activeColors, hoverColors = [] } = manager - - 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 - : manager.legendOpacity - - return ( - - ) - })} - - ) - } - - renderInteractiveElements(): React.ReactElement { - const { manager, marks } = this - - return ( - - {marks.map((mark, index) => { - const mouseOver = (): void => - manager.onLegendMouseOver - ? manager.onLegendMouseOver(mark.bin) - : undefined - const mouseLeave = (): void => - manager.onLegendMouseLeave - ? manager.onLegendMouseLeave() - : undefined - const click = manager.onLegendClick - ? (): void => manager.onLegendClick?.(mark.bin) - : undefined - - const cursor = click ? "pointer" : "default" - - return ( - - {/* for hover interaction */} - - - ) - })} - - ) - } - - render(): React.ReactElement { - return ( - - {this.renderSwatches()} - {this.renderLabels()} - {!this.manager.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..70d996cc34a --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegend.ts @@ -0,0 +1,398 @@ +import { HorizontalAlign } from "@ourworldindata/types" +import { + CategoricalBin, + ColorScaleBin, + NumericBin, +} from "../color/ColorScaleBin" +import { computed } from "mobx" +import { + CATEGORICAL_BIN_MIN_WIDTH, + DEFAULT_NUMERIC_BIN_SIZE, + DEFAULT_TICK_SIZE, + MINIMUM_LABEL_DISTANCE, +} from "./HorizontalColorLegendConstants" +import { + BASE_FONT_SIZE, + 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 { + numericBins: ColorScaleBin[] + x?: number + y?: number + width?: number + maxWidth?: number + align?: HorizontalAlign + fontSize?: number + binSize?: number + equalSizeBins?: boolean + title?: string + tickSize?: 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 { + props: HorizontalNumericColorLegendProps + constructor(props: HorizontalNumericColorLegendProps) { + this.props = props + } + + static height(props: HorizontalNumericColorLegendProps): number { + const legend = new HorizontalNumericColorLegend(props) + return legend.height + } + + @computed get y(): number { + return this.props.y ?? 0 + } + + @computed private get tickSize(): number { + return this.props.tickSize ?? DEFAULT_TICK_SIZE + } + + @computed private get bins(): ColorScaleBin[] { + return this.props.numericBins ?? [] + } + + @computed private get visibleBins(): ColorScaleBin[] { + return this.bins.filter((bin) => !bin.isHidden) + } + + @computed private get numericBins(): NumericBin[] { + return this.visibleBins.filter( + (bin): bin is NumericBin => bin instanceof NumericBin + ) + } + + @computed get binSize(): number { + return this.props.binSize ?? DEFAULT_NUMERIC_BIN_SIZE + } + + @computed protected get fontSize(): number { + return this.props.fontSize ?? BASE_FONT_SIZE + } + + @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.maxWidth ?? this.props.width ?? 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.width === undefined && this.props.maxWidth !== 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 { x = 0 } = this.props + const { width, maxWidth } = this + const { align } = this.props + const widthDiff = maxWidth - width + if (align === HorizontalAlign.center) { + return x + widthDiff / 2 + } else if (align === HorizontalAlign.right) { + return x + widthDiff + } else { + return x // 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 { title: 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 { binSize: 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.tickSize + + 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.tickSize + + 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.y, 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..0f7b64305a5 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalNumericColorLegendComponent.tsx @@ -0,0 +1,250 @@ +import React from "react" +import { HorizontalNumericColorLegend } from "./HorizontalNumericColorLegend" +import { action, computed } from "mobx" +import { + Color, + 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" +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 + x?: number + focusBin?: ColorScaleBin + textColor?: Color + binStrokeColor?: Color + binStrokeWidth?: number + opacity?: number + onMouseLeave?: () => void + onMouseOver?: (d: ColorScaleBin) => void +}> { + base: React.RefObject = React.createRef() + + @computed private get legend(): HorizontalNumericColorLegend { + 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 { focusBin } = this.props + const { + onMouseLeave: onLegendMouseLeave, + onMouseOver: 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 (focusBin && onLegendMouseLeave) return onLegendMouseLeave() + return + } + + // If inside legend bounds, trigger onMouseOver with the bin closest to the cursor. + let newFocusBin: ColorScaleBin | undefined + positionedBins.forEach((bin) => { + if (mouse.x >= bin.x && mouse.x <= bin.x + bin.width) + newFocusBin = bin.bin + }) + + if (newFocusBin && onLegendMouseOver) onLegendMouseOver(newFocusBin) + } + } + + 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 { binStrokeColor, binStrokeWidth } = this + const { numericLabels, binSize, positionedBins, height } = this.legend + const { focusBin, opacity } = this.props + + const bottomY = this.legend.y + height + + return ( + + + {numericLabels.map((label, index) => ( + + ))} + + + {sortBy( + positionedBins.map((positionedBin, index) => { + const bin = positionedBin.bin + const isFocus = focusBin && bin.equals(focusBin) + 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.y + + height - + this.legend.legendTitle.height + + this.legend.legendTitleFontSize * 0.2, + { textProps: { fill: this.textColor } } + )} + + ) + } +} + +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.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index bc77d3dee6f..3ad36385de6 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -293,16 +293,14 @@ describe("externalLegendBins", () => { const chart = new LineChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart.externalLegend).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.externalLegend?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index dd54175a663..0024676efd6 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, @@ -103,10 +103,6 @@ import { import { MultiColorPolyline } from "../scatterCharts/MultiColorPolyline" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" import { darkenColorForLine } from "../color/ColorUtils" -import { - HorizontalColorLegendManager, - HorizontalNumericColorLegend, -} from "../horizontalColorLegend/HorizontalColorLegends" import { AnnotationsMap, getAnnotationsForSeries, @@ -115,6 +111,8 @@ import { getSeriesName, } from "./LineChartHelpers" import { FocusArray } from "../focus/FocusArray.js" +import { HorizontalNumericColorLegend } from "../horizontalColorLegend/HorizontalNumericColorLegend" +import { HorizontalNumericColorLegendComponent } from "../horizontalColorLegend/HorizontalNumericColorLegendComponent" const LINE_CHART_CLASS_NAME = "LineChart" @@ -344,11 +342,7 @@ export class LineChart bounds?: Bounds manager: LineChartManager }> - implements - ChartInterface, - AxisManager, - ColorScaleManager, - HorizontalColorLegendManager + implements ChartInterface, AxisManager, ColorScaleManager { base: React.RefObject = React.createRef() @@ -498,7 +492,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 ) } @@ -930,8 +924,15 @@ export class LineChart } renderColorLegend(): React.ReactElement | void { - if (this.hasColorLegend) - return + if (this.colorLegend) + return ( + + ) } /** @@ -1157,9 +1158,20 @@ export class LineChart return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } - @computed get numericLegend(): HorizontalNumericColorLegend | undefined { + @computed get colorLegend(): HorizontalNumericColorLegend | undefined { return this.hasColorScale && this.manager.showLegend - ? new HorizontalNumericColorLegend({ manager: this }) + ? 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 } @@ -1173,8 +1185,8 @@ export class LineChart : undefined } - @computed get legendHeight(): number { - return this.numericLegend?.height ?? 0 + @computed get colorLegendHeight(): number { + return this.colorLegend?.height ?? 0 } // End of color legend props @@ -1467,7 +1479,7 @@ export class LineChart return this.dualAxis.horizontalAxis } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const numericLegendData = this.hasColorScale ? this.numericLegendData @@ -1484,15 +1496,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 6a3c29eb8c7..37b81c26b9b 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -16,11 +16,8 @@ import { } from "@ourworldindata/utils" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, - 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" @@ -71,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" @@ -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,96 @@ export class MapChart return this.categoryLegendHeight + this.numericLegendHeight + 10 } + @computed private get hasCategoryLegend(): boolean { + return this.categoricalLegendData.length > 1 + } + + @computed private get hasNumericLegend(): boolean { + return this.numericLegendData.length > 1 + } + @computed get numericLegendHeight(): number { - return this.numericLegend ? this.numericLegend.height : 0 + // 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 get categoryLegend(): + @computed private get categoryLegend(): | HorizontalCategoricalColorLegend | undefined { - return this.categoricalLegendData.length > 1 - ? new HorizontalCategoricalColorLegend({ manager: this }) + return this.hasCategoryLegend + ? new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + align: this.legendAlign, + maxWidth: this.legendMaxWidth, + categoricalBins: this.categoricalLegendData, + }) : undefined } - @computed get numericLegend(): HorizontalNumericColorLegend | undefined { - return this.numericLegendData.length > 1 - ? new HorizontalNumericColorLegend({ manager: this }) + @computed private get numericLegend(): + | HorizontalNumericColorLegend + | undefined { + return this.hasNumericLegend + ? new HorizontalNumericColorLegend({ + fontSize: this.fontSize, + x: this.legendX, + align: this.legendAlign, + y: this.numericLegendY, + maxWidth: this.legendMaxWidth, + numericBins: this.numericLegendData, + equalSizeBins: this.equalSizeBins, + }) : undefined } + @computed get categoryLegendNumLines(): number { + // can't use categoryLegend due to a circular dependency + return this.hasCategoryLegend + ? HorizontalCategoricalColorLegend.numLines({ + fontSize: this.fontSize, + maxWidth: this.legendMaxWidth, + categoricalBins: this.categoricalLegendData, + }) + : 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 +633,32 @@ export class MapChart } renderMapLegend(): React.ReactElement { - const { numericLegend, categoryLegend } = this + const onMouseLeave = this.manager.isStatic + ? undefined + : this.onLegendMouseLeave + const onMouseOver = this.manager.isStatic + ? undefined + : this.onLegendMouseOver return ( <> - {numericLegend && ( - + {this.numericLegend && ( + )} - {categoryLegend && ( - + {this.categoryLegend && ( + )} ) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index b713209ebf9..800c2676552 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" @@ -86,7 +86,6 @@ import { } from "../lineCharts/LineChartHelpers" import { SelectionArray } from "../selection/SelectionArray" import { Halo } from "@ourworldindata/components" -import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin } from "../color/ColorScaleBin" import { OWID_NON_FOCUSED_GRAY, @@ -591,7 +590,7 @@ export class SlopeChart : 0 } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series.map( (series, index) => @@ -602,7 +601,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 5b40a3086bb..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, @@ -38,7 +38,6 @@ 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 { CategoricalColorAssigner, CategoricalColorMap, @@ -436,7 +435,7 @@ export class AbstractStackedChart return this.unstackedSeries } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series .map( @@ -449,7 +448,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 c95f9cc3c72..21d92aacbd4 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -58,10 +58,6 @@ import { makeTooltipRoundingNotice, makeTooltipToleranceNotice, } from "../tooltip/Tooltip" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, -} from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" import { ColorScale, ColorScaleManager } from "../color/ColorScale" @@ -88,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 @@ -262,7 +260,7 @@ export class MarimekkoChart manager: MarimekkoChartManager containerElement?: HTMLDivElement }> - implements ChartInterface, HorizontalColorLegendManager, ColorScaleManager + implements ChartInterface, ColorScaleManager { base: React.RefObject = React.createRef() @@ -898,7 +896,12 @@ export class MarimekkoChart } @computed private get legend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + align: this.legendAlign, + maxWidth: this.legendWidth, + categoricalBins: this.categoricalLegendData, + }) } @computed private get formatColumn(): CoreColumn { @@ -1027,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 && ( { const chart = new StackedAreaChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["externalLegend"]).toBeUndefined() + expect(chart.externalLegend).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.externalLegend?.categoricalBins?.length).toEqual(2) }) }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 0224e532810..cf666440a2d 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 @@ -307,13 +308,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 ) } @@ -330,7 +327,12 @@ export class StackedBarChart @computed private get horizontalColorLegend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + align: this.legendAlign, + maxWidth: this.legendWidth, + categoricalBins: this.categoricalLegendData, + }) } @computed get formatColumn(): CoreColumn { @@ -484,16 +486,26 @@ 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.test.ts b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts index 4190b4205d0..8dfcdd92411 100755 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.test.ts @@ -371,19 +371,17 @@ describe("showLegend", () => { const chart = new StackedDiscreteBarChart({ manager: { ...baseManager, showLegend: true }, }) - expect(chart["legend"].height).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["legend"].height).toEqual(0) - expect(chart["categoricalLegendData"].length).toEqual(0) - expect(chart["externalLegend"]?.categoricalLegendData?.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 63317a0eae4..aaf95496be5 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, @@ -66,10 +66,6 @@ import { } from "../tooltip/Tooltip" import { StackedPoint, StackedSeries } from "./StackedConstants" import { ColorSchemes } from "../color/ColorSchemes" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, -} from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { isDarkColor } from "../color/ColorUtils" import { HorizontalAxis } from "../axis/Axis" @@ -80,6 +76,8 @@ import { easeQuadOut } from "d3-ease" import { bind } from "decko" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner.js" import { TextWrap } from "@ourworldindata/components" +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) const SOFT_MAX_LABEL_WIDTH = 90 @@ -131,7 +129,7 @@ export class StackedDiscreteBarChart manager: StackedDiscreteBarChartManager containerElement?: HTMLDivElement }> - implements ChartInterface, HorizontalColorLegendManager + implements ChartInterface { base: React.RefObject = React.createRef() @@ -525,10 +523,10 @@ export class StackedDiscreteBarChart return this.showLegend ? this.legendBins : [] } - @computed get externalLegend(): HorizontalColorLegendManager | undefined { + @computed get externalLegend(): ExternalLegendProps | undefined { if (!this.showLegend) { return { - categoricalLegendData: this.legendBins, + categoricalBins: this.legendBins, } } return undefined @@ -546,8 +544,13 @@ export class StackedDiscreteBarChart this.focusSeriesName = undefined } - @computed private get legend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + @computed get legend(): HorizontalCategoricalColorLegend { + return new HorizontalCategoricalColorLegend({ + fontSize: this.fontSize, + align: this.legendAlign, + maxWidth: this.legendWidth, + categoricalBins: this.categoricalLegendData, + }) } @computed private get formatColumn(): CoreColumn { @@ -724,7 +727,23 @@ export class StackedDiscreteBarChart renderLegend(): React.ReactElement | void { if (!this.showLegend) return - return + + const onMouseLeave = this.manager.isStatic + ? undefined + : this.onLegendMouseLeave + const onMouseOver = this.manager.isStatic + ? undefined + : this.onLegendMouseOver + + return ( + + ) } renderStatic(): React.ReactElement {