diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts index 04c95cb6b9..e39a902852 100755 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts @@ -531,7 +531,11 @@ describe("colors & legend", () => { }) it("legend contains every continent for which there is data (before timeline filter)", () => { - expect(chart.legendItems.map((item) => item.label).sort()).toEqual([ + expect( + chart.verticalColorLegendBins + .map((item) => item.type === "categorical" && item.label) + .sort() + ).toEqual([ "Africa", "Europe", "North America", diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 072e02bd29..02acd74115 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -61,7 +61,10 @@ import { ConnectedScatterLegend, ConnectedScatterLegendManager, } from "./ConnectedScatterLegend" -import { VerticalColorLegend } from "../verticalColorLegend/VerticalColorLegend" +import { + VerticalColorLegend, + VerticalColorLegendBin, +} from "../verticalColorLegend/VerticalColorLegend" import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { DualAxisComponent } from "../axis/AxisViews" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" @@ -95,7 +98,7 @@ import { ColorScaleConfigDefaults, } from "../color/ColorScaleConfig" import { SelectionArray } from "../selection/SelectionArray" -import { ColorScaleBin } from "../color/ColorScaleBin" +import { CategoricalBin } from "../color/ColorScaleBin" import { ScatterSizeLegend, ScatterSizeLegendManager, @@ -509,17 +512,13 @@ export class ScatterPlotChart @computed private get verticalColorLegend(): VerticalColorLegend { return new VerticalColorLegend({ - maxLegendWidth: this.maxLegendWidth, + bins: this.verticalColorLegendBins, + maxWidth: this.sidebarMaxWidth, + legendTitle: this.colorScale.legendDescription, fontSize: this.fontSize, - legendItems: this.legendItems, - legendTitle: this.legendTitle, }) } - @computed get maxLegendWidth(): number { - return this.sidebarMaxWidth - } - @computed private get sidebarMinWidth(): number { return Math.max(this.bounds.width * 0.1, 60) } @@ -687,16 +686,27 @@ export class ScatterPlotChart return this.transformedTable.get(this.colorColumnSlug) } - @computed get legendItems(): ColorScaleBin[] { - return this.colorScale.legendBins.filter( + @computed get verticalColorLegendBins(): VerticalColorLegendBin[] { + const bins = this.colorScale.legendBins.filter( (bin) => this.colorsInUse.includes(bin.color) && bin.label !== NO_DATA_LABEL ) - } - @computed get legendTitle(): string | undefined { - return this.colorScale.legendDescription + return bins.map((bin) => + bin instanceof CategoricalBin + ? { + type: "categorical", + color: bin.color, + label: bin.label ?? "", + } + : { + type: "numeric", + color: bin.color, + minLabel: bin.minText, + maxLabel: bin.maxText, + } + ) } @computed get sizeScale(): ScaleLinear { @@ -767,7 +777,7 @@ export class ScatterPlotChart verticalColorLegend, } = this - const hasLegendItems = this.legendItems.length > 0 + const hasLegendItems = this.verticalColorLegendBins.length > 0 const verticalLegendHeight = hasLegendItems ? verticalColorLegend.height : 0 @@ -789,7 +799,7 @@ export class ScatterPlotChart (arrowLegendHeight > 0 ? legendPadding : 0) const noDataSectionBounds = new Bounds( - this.legendX, + this.verticalColorLegendX, yNoDataSection, sidebarWidth, bounds.height - yNoDataSection @@ -798,7 +808,7 @@ export class ScatterPlotChart const separatorLine = (y: number): React.ReactElement | null => y > bounds.top ? ( {sizeLegend && ( <> {separatorLine(ySizeLegend)} - {sizeLegend.render(this.legendX, ySizeLegend)} + {sizeLegend.render( + this.verticalColorLegendX, + ySizeLegend + )} )} {arrowLegend && ( @@ -853,7 +866,10 @@ export class ScatterPlotChart className="clickable" onClick={this.onToggleEndpoints} > - {arrowLegend.render(this.legendX, yArrowLegend)} + {arrowLegend.render( + this.verticalColorLegendX, + yArrowLegend + )} )} @@ -999,11 +1015,11 @@ export class ScatterPlotChart ) } - @computed get legendY(): number { + @computed get verticalColorLegendY(): number { return this.bounds.top } - @computed get legendX(): number { + @computed get verticalColorLegendX(): number { return this.bounds.right - this.sidebarWidth } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 45bb6d0c5b..0224e53281 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -18,7 +18,7 @@ import { DualAxisComponent } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" import { VerticalColorLegend, - LegendItem, + VerticalColorLegendCategoricalBin, } from "../verticalColorLegend/VerticalColorLegend" import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" @@ -259,12 +259,12 @@ export class StackedBarChart ) } - // used by - @computed get legendItems(): (LegendItem & - Required>)[] { + @computed + get verticalColorLegendBins(): VerticalColorLegendCategoricalBin[] { return this.series .map((series) => { return { + type: "categorical" as const, label: series.seriesName, color: series.color, } @@ -274,7 +274,7 @@ export class StackedBarChart // used by @computed get categoricalLegendData(): CategoricalBin[] { - return this.legendItems.map( + return this.verticalColorLegendBins.map( (legendItem, index) => new CategoricalBin({ index, @@ -320,9 +320,11 @@ export class StackedBarChart @computed private get verticalColorLegend(): VerticalColorLegend { return new VerticalColorLegend({ - maxLegendWidth: this.maxLegendWidth, + bins: this.verticalColorLegendBins, + maxWidth: this.showHorizontalLegend + ? this.bounds.width + : this.sidebarMaxWidth, fontSize: this.fontSize, - legendItems: this.legendItems, }) } @@ -477,20 +479,21 @@ export class StackedBarChart if (!showLegend) return + const x = this.showHorizontalLegend + ? this.bounds.left + : this.bounds.right - this.sidebarWidth + const y = this.bounds.top + return showHorizontalLegend ? ( ) : ( ) } diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx index 251bcbb168..add6163541 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx @@ -11,26 +11,30 @@ export default { } const props: VerticalColorLegendProps = { - maxLegendWidth: 500, + maxWidth: 500, legendTitle: "Legend Title", - legendItems: [ + bins: [ { + type: "categorical", label: "Canada", color: "red", }, { + type: "categorical", label: "Mexico", color: "green", }, ], - activeColors: ["red", "green"], } export const CategoricalBins = (): React.ReactElement => { const verticalColorLegend = new VerticalColorLegend(props) return ( - + ) } diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts index ff9d6b648c..15c634a3f3 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts @@ -7,53 +7,67 @@ import { } from "../core/GrapherConstants" import { Color } from "@ourworldindata/types" -export interface VerticalColorLegendProps { - legendItems: LegendItem[] - maxLegendWidth?: number - fontSize?: number - legendTitle?: string +interface Bin { + color: Color } -export interface LegendItem { - label?: string - minText?: string - maxText?: string - color: Color +export interface VerticalColorLegendCategoricalBin extends Bin { + type: "categorical" + label: string +} + +export interface VerticalColorLegendNumericBin extends Bin { + type: "numeric" + minLabel: string + maxLabel: string } -interface SizedLegendSeries { +export type VerticalColorLegendBin = + | VerticalColorLegendCategoricalBin + | VerticalColorLegendNumericBin + +interface PlacedBin extends Bin { textWrap: TextWrap - color: Color width: number height: number yOffset: number } +export interface VerticalColorLegendProps { + bins: VerticalColorLegendBin[] + maxWidth?: number + fontSize?: number + legendTitle?: string +} + export class VerticalColorLegend { - rectPadding = 5 - lineHeight = 5 + /** Margin between the swatch and the label */ + swatchMarginRight = 5 + + /** Vertical space between two bins */ + verticalBinMargin = 5 - props: VerticalColorLegendProps + private props: VerticalColorLegendProps constructor(props: VerticalColorLegendProps) { this.props = props } - @computed private get maxLegendWidth(): number { - return this.props.maxLegendWidth ?? 100 + @computed private get maxWidth(): number { + return this.props.maxWidth ?? 100 } @computed private get fontSize(): number { return GRAPHER_FONT_SCALE_11_2 * (this.props.fontSize ?? BASE_FONT_SIZE) } - @computed get rectSize(): number { + @computed get swatchSize(): number { return Math.round(this.fontSize / 1.4) } @computed get title(): TextWrap | undefined { if (!this.props.legendTitle) return undefined return new TextWrap({ - maxWidth: this.maxLegendWidth, + maxWidth: this.maxWidth, fontSize: this.fontSize, fontWeight: 700, lineHeight: 1, @@ -66,28 +80,35 @@ export class VerticalColorLegend { return this.title.height + 5 } - @computed get series(): SizedLegendSeries[] { - const { fontSize, rectSize, rectPadding, titleHeight, lineHeight } = - this + @computed get placedBins(): PlacedBin[] { + const { + fontSize, + swatchSize, + swatchMarginRight, + titleHeight, + verticalBinMargin, + } = this let runningYOffset = titleHeight - return this.props.legendItems.map((series) => { - let label = series.label - // infer label for numeric bins - if (!label && series.minText && series.maxText) { - label = `${series.minText} – ${series.maxText}` + return this.props.bins.map((series) => { + let label + if (series.type === "categorical") { + label = series.label + } else { + // infer label for numeric bins + label = `${series.minLabel} – ${series.maxLabel}` } const textWrap = new TextWrap({ - maxWidth: this.maxLegendWidth, + maxWidth: this.maxWidth, fontSize, lineHeight: 1, text: label ?? "", }) - const width = rectSize + rectPadding + textWrap.width - const height = Math.max(textWrap.height, rectSize) + const width = swatchSize + swatchMarginRight + textWrap.width + const height = Math.max(textWrap.height, swatchSize) const yOffset = runningYOffset - runningYOffset += height + lineHeight + runningYOffset += height + verticalBinMargin return { textWrap, @@ -100,7 +121,7 @@ export class VerticalColorLegend { } @computed get width(): number { - const widths = this.series.map((series) => series.width) + const widths = this.placedBins.map((series) => series.width) if (this.title) widths.push(this.title.width) return max(widths) ?? 0 } @@ -108,8 +129,8 @@ export class VerticalColorLegend { @computed get height(): number { return ( this.titleHeight + - sum(this.series.map((series) => series.height)) + - this.lineHeight * this.series.length + sum(this.placedBins.map((series) => series.height)) + + this.verticalBinMargin * this.placedBins.length ) } } diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx index 884587aa33..46207409c3 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx @@ -3,27 +3,34 @@ import React from "react" import { Color, makeIdForHumanConsumption } from "@ourworldindata/utils" import { VerticalColorLegend } from "./VerticalColorLegend" +interface VerticalColorLegendComponentProps { + legend: VerticalColorLegend + + // positioning + x?: number + y?: number + + // state + activeColors?: Color[] // inactive colors are grayed out + focusColors?: Color[] // focused colors are bolded + + // interaction + onClick?: (color: string) => void + onMouseOver?: (color: string) => void + onMouseLeave?: () => void +} + export function VerticalColorLegendComponent({ legend, x = 0, y = 0, activeColors, focusColors, - onLegendClick, - onLegendMouseOver, - onLegendMouseLeave, -}: { - legend: VerticalColorLegend - x?: number - y?: number - activeColors?: Color[] // inactive colors are grayed out - focusColors?: Color[] // focused colors are bolded - onLegendClick?: (color: string) => void - onLegendMouseOver?: (color: string) => void - onLegendMouseLeave?: () => void -}): React.ReactElement { - const isInteractive = - onLegendClick || onLegendMouseOver || onLegendMouseLeave + onClick, + onMouseOver, + onMouseLeave, +}: VerticalColorLegendComponentProps): React.ReactElement { + const isInteractive = onClick || onMouseOver || onMouseLeave return ( - + {isInteractive && ( )} @@ -65,10 +72,10 @@ function Labels({ }): React.ReactElement { return ( - {legend.series.map((series) => { + {legend.placedBins.map((series) => { const isFocus = focusColors?.includes(series.color) ?? false - const textX = x + legend.rectSize + legend.rectPadding + const textX = x + legend.swatchSize + legend.swatchMarginRight const textY = y + series.yOffset return ( @@ -104,10 +111,10 @@ function Swatches({ }): React.ReactElement { return ( - {legend.series.map((series) => { + {legend.placedBins.map((series) => { const isActive = activeColors?.includes(series.color) - const textX = x + legend.rectSize + legend.rectPadding + const textX = x + legend.swatchSize + legend.swatchMarginRight const textY = y + series.yOffset const renderedTextPosition = @@ -118,9 +125,9 @@ function Swatches({ id={makeIdForHumanConsumption(series.textWrap.text)} key={series.textWrap.text} x={x} - y={renderedTextPosition[1] - legend.rectSize} - width={legend.rectSize} - height={legend.rectSize} + y={renderedTextPosition[1] - legend.swatchSize} + width={legend.swatchSize} + height={legend.swatchSize} fill={isActive ? series.color : "#ccc"} /> ) @@ -133,26 +140,26 @@ function InteractiveElement({ x, y, legend, - onLegendClick, - onLegendMouseOver, - onLegendMouseLeave, + onClick, + onMouseOver, + onMouseLeave, }: { x: number y: number legend: VerticalColorLegend - onLegendClick?: (color: string) => void - onLegendMouseOver?: (color: string) => void - onLegendMouseLeave?: () => void + onClick?: (color: string) => void + onMouseOver?: (color: string) => void + onMouseLeave?: () => void }): React.ReactElement { return ( - {legend.series.map((series) => { - const mouseOver = onLegendMouseOver - ? (): void => onLegendMouseOver(series.color) + {legend.placedBins.map((series) => { + const mouseOver = onMouseOver + ? (): void => onMouseOver(series.color) : undefined - const mouseLeave = onLegendMouseLeave - const click = onLegendClick - ? (): void => onLegendClick(series.color) + const mouseLeave = onMouseLeave + const click = onClick + ? (): void => onClick(series.color) : undefined const cursor = click ? "pointer" : "default" @@ -168,9 +175,13 @@ function InteractiveElement({ >