diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts index 04c95cb6b9b..e39a9028522 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 9f65ef78db9..02acd741153 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -63,8 +63,9 @@ import { } from "./ConnectedScatterLegend" import { VerticalColorLegend, - VerticalColorLegendManager, + VerticalColorLegendBin, } from "../verticalColorLegend/VerticalColorLegend" +import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { DualAxisComponent } from "../axis/AxisViews" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" @@ -97,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, @@ -127,7 +128,6 @@ export class ScatterPlotChart ConnectedScatterLegendManager, ScatterSizeLegendManager, ChartInterface, - VerticalColorLegendManager, ColorScaleManager { // currently hovered legend color @@ -510,12 +510,13 @@ export class ScatterPlotChart return this.tooltipState.target?.series } - @computed private get legendDimensions(): VerticalColorLegend { - return new VerticalColorLegend({ manager: this }) - } - - @computed get maxLegendWidth(): number { - return this.sidebarMaxWidth + @computed private get verticalColorLegend(): VerticalColorLegend { + return new VerticalColorLegend({ + bins: this.verticalColorLegendBins, + maxWidth: this.sidebarMaxWidth, + legendTitle: this.colorScale.legendDescription, + fontSize: this.fontSize, + }) } @computed private get sidebarMinWidth(): number { @@ -527,10 +528,10 @@ export class ScatterPlotChart } @computed.struct get sidebarWidth(): number { - const { legendDimensions, sidebarMinWidth, sidebarMaxWidth } = this + const { verticalColorLegend, sidebarMinWidth, sidebarMaxWidth } = this return Math.max( - Math.min(legendDimensions.width, sidebarMaxWidth), + Math.min(verticalColorLegend.width, sidebarMaxWidth), sidebarMinWidth ) } @@ -685,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 { @@ -762,12 +774,12 @@ export class ScatterPlotChart sizeLegend, sidebarWidth, comparisonLines, - legendDimensions, + verticalColorLegend, } = this - const hasLegendItems = this.legendItems.length > 0 + const hasLegendItems = this.verticalColorLegendBins.length > 0 const verticalLegendHeight = hasLegendItems - ? legendDimensions.height + ? verticalColorLegend.height : 0 const sizeLegendHeight = sizeLegend?.height ?? 0 const arrowLegendHeight = arrowLegend?.height ?? 0 @@ -787,7 +799,7 @@ export class ScatterPlotChart (arrowLegendHeight > 0 ? legendPadding : 0) const noDataSectionBounds = new Bounds( - this.legendX, + this.verticalColorLegendX, yNoDataSection, sidebarWidth, bounds.height - yNoDataSection @@ -796,7 +808,7 @@ export class ScatterPlotChart const separatorLine = (y: number): React.ReactElement | null => y > bounds.top ? ( ))} {this.points} - + {sizeLegend && ( <> {separatorLine(ySizeLegend)} - {sizeLegend.render(this.legendX, ySizeLegend)} + {sizeLegend.render( + this.verticalColorLegendX, + ySizeLegend + )} )} {arrowLegend && ( @@ -842,7 +866,10 @@ export class ScatterPlotChart className="clickable" onClick={this.onToggleEndpoints} > - {arrowLegend.render(this.legendX, yArrowLegend)} + {arrowLegend.render( + this.verticalColorLegendX, + yArrowLegend + )} )} @@ -988,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 6dcbab4f96c..0224e532810 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -18,9 +18,9 @@ import { DualAxisComponent } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" import { VerticalColorLegend, - VerticalColorLegendManager, - LegendItem, + VerticalColorLegendCategoricalBin, } from "../verticalColorLegend/VerticalColorLegend" +import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" import { Tooltip, @@ -144,7 +144,7 @@ class StackedBarSegment extends React.Component { @observer export class StackedBarChart extends AbstractStackedChart - implements VerticalColorLegendManager, ColorScaleManager + implements ColorScaleManager { readonly minBarSpacing = 4 @@ -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, @@ -319,7 +319,13 @@ export class StackedBarChart } @computed private get verticalColorLegend(): VerticalColorLegend { - return new VerticalColorLegend({ manager: this }) + return new VerticalColorLegend({ + bins: this.verticalColorLegendBins, + maxWidth: this.showHorizontalLegend + ? this.bounds.width + : this.sidebarMaxWidth, + fontSize: this.fontSize, + }) } @computed @@ -467,16 +473,28 @@ export class StackedBarChart renderLegend(): React.ReactElement | void { const { - manager: { showLegend }, + manager: { showLegend, isStatic }, showHorizontalLegend, } = this 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 42d9e94b5cb..add61635410 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx @@ -1,34 +1,40 @@ import React from "react" import { VerticalColorLegend, - VerticalColorLegendManager, + VerticalColorLegendProps, } from "./VerticalColorLegend" +import { VerticalColorLegendComponent } from "./VerticalColorLegendComponent" export default { title: "VerticalColorLegend", component: VerticalColorLegend, } -const manager: VerticalColorLegendManager = { - maxLegendWidth: 500, +const props: VerticalColorLegendProps = { + 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 new file mode 100644 index 00000000000..cc0aa12e1cf --- /dev/null +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts @@ -0,0 +1,136 @@ +import { sum, max } from "@ourworldindata/utils" +import { TextWrap } from "@ourworldindata/components" +import { computed } from "mobx" +import { + GRAPHER_FONT_SCALE_11_2, + BASE_FONT_SIZE, +} from "../core/GrapherConstants" +import { Color } from "@ourworldindata/types" + +interface Bin { + color: Color +} + +export interface VerticalColorLegendCategoricalBin extends Bin { + type: "categorical" + label: string +} + +export interface VerticalColorLegendNumericBin extends Bin { + type: "numeric" + minLabel: string + maxLabel: string +} + +export type VerticalColorLegendBin = + | VerticalColorLegendCategoricalBin + | VerticalColorLegendNumericBin + +export interface PlacedBin extends Bin { + textWrap: TextWrap + width: number + height: number + yOffset: number +} + +export interface VerticalColorLegendProps { + bins: VerticalColorLegendBin[] + maxWidth?: number + fontSize?: number + legendTitle?: string +} + +export class VerticalColorLegend { + /** Margin between the swatch and the label */ + swatchMarginRight = 5 + + /** Vertical space between two bins */ + verticalBinMargin = 5 + + private props: VerticalColorLegendProps + constructor(props: VerticalColorLegendProps) { + this.props = props + } + + @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 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.maxWidth, + fontSize: this.fontSize, + fontWeight: 700, + lineHeight: 1, + text: this.props.legendTitle, + }) + } + + @computed private get titleHeight(): number { + if (!this.title) return 0 + return this.title.height + 5 + } + + @computed get placedBins(): PlacedBin[] { + const { + fontSize, + swatchSize, + swatchMarginRight, + titleHeight, + verticalBinMargin, + } = this + + let runningYOffset = titleHeight + 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.maxWidth, + fontSize, + lineHeight: 1, + text: label ?? "", + }) + const width = swatchSize + swatchMarginRight + textWrap.width + const height = Math.max(textWrap.height, swatchSize) + const yOffset = runningYOffset + + runningYOffset += height + verticalBinMargin + + return { + textWrap, + color: series.color, + width, + height, + yOffset, + } + }) + } + + @computed get width(): number { + const widths = this.placedBins.map((series) => series.width) + if (this.title) widths.push(this.title.width) + return max(widths) ?? 0 + } + + @computed get height(): number { + return ( + this.titleHeight + + sum(this.placedBins.map((series) => series.height)) + + this.verticalBinMargin * this.placedBins.length + ) + } +} diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx deleted file mode 100644 index ea659e517e2..00000000000 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import React from "react" -import { sum, max, makeIdForHumanConsumption } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" -import { computed } from "mobx" -import { observer } from "mobx-react" -import { - GRAPHER_FONT_SCALE_11_2, - BASE_FONT_SIZE, -} from "../core/GrapherConstants" -import { Color } from "@ourworldindata/types" - -export interface VerticalColorLegendManager { - maxLegendWidth?: number - fontSize?: number - legendItems: LegendItem[] - legendTitle?: string - onLegendMouseOver?: (color: string) => void - onLegendClick?: (color: string) => void - onLegendMouseLeave?: () => void - legendX?: number - legendY?: number - activeColors: Color[] - focusColors?: Color[] - isStatic?: boolean -} - -export interface LegendItem { - label?: string - minText?: string - maxText?: string - color: Color -} - -interface SizedLegendSeries { - textWrap: TextWrap - color: Color - width: number - height: number - yOffset: number -} - -@observer -export class VerticalColorLegend extends React.Component<{ - manager: VerticalColorLegendManager -}> { - @computed get manager(): VerticalColorLegendManager { - return this.props.manager - } - - @computed private get maxLegendWidth(): number { - return this.manager.maxLegendWidth ?? 100 - } - - @computed private get fontSize(): number { - return ( - GRAPHER_FONT_SCALE_11_2 * (this.manager.fontSize ?? BASE_FONT_SIZE) - ) - } - @computed private get rectSize(): number { - return Math.round(this.fontSize / 1.4) - } - - private rectPadding = 5 - private lineHeight = 5 - - @computed private get title(): TextWrap | undefined { - if (!this.manager.legendTitle) return undefined - return new TextWrap({ - maxWidth: this.maxLegendWidth, - fontSize: this.fontSize, - fontWeight: 700, - lineHeight: 1, - text: this.manager.legendTitle, - }) - } - - @computed private get titleHeight(): number { - if (!this.title) return 0 - return this.title.height + 5 - } - - @computed private get series(): SizedLegendSeries[] { - const { - manager, - fontSize, - rectSize, - rectPadding, - titleHeight, - lineHeight, - } = this - - let runningYOffset = titleHeight - return manager.legendItems.map((series) => { - let label = series.label - // infer label for numeric bins - if (!label && series.minText && series.maxText) { - label = `${series.minText} – ${series.maxText}` - } - const textWrap = new TextWrap({ - maxWidth: this.maxLegendWidth, - fontSize, - lineHeight: 1, - text: label ?? "", - }) - const width = rectSize + rectPadding + textWrap.width - const height = Math.max(textWrap.height, rectSize) - const yOffset = runningYOffset - - runningYOffset += height + lineHeight - - return { - textWrap, - color: series.color, - width, - height, - yOffset, - } - }) - } - - @computed get width(): number { - const widths = this.series.map((series) => series.width) - if (this.title) widths.push(this.title.width) - return max(widths) ?? 0 - } - - @computed get height(): number { - return ( - this.titleHeight + - sum(this.series.map((series) => series.height)) + - this.lineHeight * this.series.length - ) - } - - @computed get legendX(): number { - return this.manager.legendX ?? 0 - } - - @computed get legendY(): number { - return this.manager.legendY ?? 0 - } - - renderLabels(): React.ReactElement { - const { series, manager, rectSize, rectPadding } = this - const { focusColors } = manager - - return ( - - {series.map((series) => { - const isFocus = focusColors?.includes(series.color) ?? false - - const textX = this.legendX + rectSize + rectPadding - const textY = this.legendY + series.yOffset - - return ( - - {series.textWrap.render( - textX, - textY, - isFocus - ? { - textProps: { - style: { fontWeight: "bold" }, - }, - } - : undefined - )} - - ) - })} - - ) - } - - renderSwatches(): React.ReactElement { - const { manager, series, rectSize, rectPadding } = this - const { activeColors } = manager - - return ( - - {series.map((series) => { - const isActive = activeColors.includes(series.color) - - const textX = this.legendX + rectSize + rectPadding - const textY = this.legendY + series.yOffset - - const renderedTextPosition = - series.textWrap.getPositionForSvgRendering(textX, textY) - - return ( - - ) - })} - - ) - } - - renderInteractiveElements(): React.ReactElement { - const { series, manager, lineHeight } = this - const { onLegendClick, onLegendMouseOver, onLegendMouseLeave } = manager - return ( - - {series.map((series) => { - const mouseOver = onLegendMouseOver - ? (): void => onLegendMouseOver(series.color) - : undefined - const mouseLeave = onLegendMouseLeave || undefined - const click = onLegendClick - ? (): void => onLegendClick(series.color) - : undefined - - const cursor = click ? "pointer" : "default" - - return ( - - - - ) - })} - - ) - } - - render(): React.ReactElement { - return ( - - {this.title && - this.title.render(this.legendX, this.legendY, { - textProps: { - fontWeight: 700, - }, - })} - {this.renderLabels()} - {this.renderSwatches()} - {!this.manager.isStatic && this.renderInteractiveElements()} - - ) - } -} diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx new file mode 100644 index 00000000000..47db061305c --- /dev/null +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx @@ -0,0 +1,204 @@ +import React from "react" + +import { Color, makeIdForHumanConsumption } from "@ourworldindata/utils" +import { PlacedBin, 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, + onClick, + onMouseOver, + onMouseLeave, +}: VerticalColorLegendComponentProps): React.ReactElement { + const isInteractive = onClick || onMouseOver || onMouseLeave + + return ( + + {legend.title && + legend.title.render(x, y, { textProps: { fontWeight: 700 } })} + + + {legend.placedBins.map((bin) => ( + + + + {legend.placedBins.map((bin) => ( + + ))} + + + {isInteractive && ( + + {legend.placedBins.map((bin) => ( + + ))} + + )} + + ) +} + +function Label({ + bin, + x, + y, + focusColors, + swatchSize, + swatchMarginRight, +}: { + bin: PlacedBin + x: number + y: number + swatchSize: number + swatchMarginRight: number + focusColors?: Color[] +}): React.ReactElement { + const isFocus = focusColors?.includes(bin.color) ?? false + + const textX = x + swatchSize + swatchMarginRight + const textY = y + bin.yOffset + + return bin.textWrap.render( + textX, + textY, + isFocus + ? { + textProps: { + style: { fontWeight: "bold" }, + }, + } + : undefined + ) +} + +function Swatch({ + bin, + x, + y, + swatchSize, + swatchMarginRight, + activeColors, +}: { + bin: PlacedBin + x: number + y: number + swatchSize: number + swatchMarginRight: number + activeColors?: Color[] +}): React.ReactElement { + const isActive = activeColors?.includes(bin.color) + + const textX = x + swatchSize + swatchMarginRight + const textY = y + bin.yOffset + + const renderedTextPosition = bin.textWrap.getPositionForSvgRendering( + textX, + textY + ) + + return ( + + ) +} + +function InteractiveElement({ + bin, + x, + y, + verticalBinMargin, + onClick, + onMouseOver, + onMouseLeave, +}: { + bin: PlacedBin + x: number + y: number + verticalBinMargin: number + onClick?: (color: string) => void + onMouseOver?: (color: string) => void + onMouseLeave?: () => void +}): React.ReactElement { + const mouseOver = onMouseOver + ? (): void => onMouseOver(bin.color) + : undefined + const mouseLeave = onMouseLeave + const click = onClick ? (): void => onClick(bin.color) : undefined + + const cursor = click ? "pointer" : "default" + + return ( + + + + ) +}