diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 3b0dba2ad0..ed23ecbc71 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -62,6 +62,7 @@ import { ConnectedScatterLegendManager, } from "./ConnectedScatterLegend" import { VerticalColorLegend } from "../verticalColorLegend/VerticalColorLegend" +import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { DualAxisComponent } from "../axis/AxisViews" import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis" @@ -506,15 +507,14 @@ export class ScatterPlotChart return this.tooltipState.target?.series } - @computed private get verticalColorLegend(): { - width: number - height: number - } { - return VerticalColorLegend.dimensions({ + @computed private get verticalColorLegend(): VerticalColorLegend { + return new VerticalColorLegend({ maxLegendWidth: this.maxLegendWidth, fontSize: this.fontSize, legendItems: this.legendItems, legendTitle: this.legendTitle, + activeColors: this.activeColors, + focusColors: this.focusColors, }) } @@ -832,18 +832,15 @@ export class ScatterPlotChart /> ))} {this.points} - {sizeLegend && ( <> diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 4fdc2e90b7..1821eda016 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -20,6 +20,7 @@ import { VerticalColorLegend, LegendItem, } from "../verticalColorLegend/VerticalColorLegend" +import { VerticalColorLegendComponent } from "../verticalColorLegend/VerticalColorLegendComponent" import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" import { Tooltip, @@ -313,14 +314,12 @@ export class StackedBarChart ) } - @computed private get verticalColorLegend(): { - width: number - height: number - } { - return VerticalColorLegend.dimensions({ + @computed private get verticalColorLegend(): VerticalColorLegend { + return new VerticalColorLegend({ maxLegendWidth: this.maxLegendWidth, fontSize: this.fontSize, legendItems: this.legendItems, + activeColors: this.activeColors, }) } @@ -480,6 +479,13 @@ export class StackedBarChart if (!showLegend) return + const eventListeners = this.manager.isStatic + ? undefined + : { + onLegendMouseOver: this.onLegendMouseOver, + onLegendMouseLeave: this.onLegendMouseLeave, + } + return showHorizontalLegend ? ( ) : ( - ) } diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx index 38f6a6bdc6..ec43b0adb8 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.stories.tsx @@ -3,6 +3,7 @@ import { VerticalColorLegend, VerticalColorLegendProps, } from "./VerticalColorLegend" +import { VerticalColorLegendComponent } from "./VerticalColorLegendComponent" export default { title: "VerticalColorLegend", @@ -26,9 +27,10 @@ const props: VerticalColorLegendProps = { } 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 0000000000..cbabfb4a43 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.ts @@ -0,0 +1,117 @@ +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" + +export interface VerticalColorLegendProps { + legendItems: LegendItem[] + maxLegendWidth?: number + fontSize?: number + legendTitle?: string + activeColors?: Color[] // inactive colors are grayed out + focusColors?: Color[] // focused colors are bolded +} + +export interface LegendItem { + label?: string + minText?: string + maxText?: string + color: Color +} + +interface SizedLegendSeries { + textWrap: TextWrap + color: Color + width: number + height: number + yOffset: number +} + +export class VerticalColorLegend { + rectPadding = 5 + lineHeight = 5 + + props: VerticalColorLegendProps + constructor(props: VerticalColorLegendProps) { + this.props = props + } + + @computed private get maxLegendWidth(): number { + return this.props.maxLegendWidth ?? 100 + } + + @computed private get fontSize(): number { + return GRAPHER_FONT_SCALE_11_2 * (this.props.fontSize ?? BASE_FONT_SIZE) + } + + @computed get rectSize(): 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, + 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 series(): SizedLegendSeries[] { + const { fontSize, rectSize, rectPadding, titleHeight, lineHeight } = + 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}` + } + 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 + ) + } +} diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx deleted file mode 100644 index 38585abcdc..0000000000 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx +++ /dev/null @@ -1,269 +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 VerticalColorLegendProps { - legendItems: LegendItem[] - maxLegendWidth?: number - fontSize?: number - 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 { - private rectPadding = 5 - private lineHeight = 5 - - static dimensions( - props: Pick< - VerticalColorLegendProps, - "legendItems" | "maxLegendWidth" | "fontSize" | "legendTitle" - > - ): { width: number; height: number } { - const legend = new VerticalColorLegend(props) - return { - width: legend.width, - height: legend.height, - } - } - - @computed private get maxLegendWidth(): number { - return this.props.maxLegendWidth ?? 100 - } - - @computed private get fontSize(): number { - return GRAPHER_FONT_SCALE_11_2 * (this.props.fontSize ?? BASE_FONT_SIZE) - } - - @computed private get rectSize(): number { - return Math.round(this.fontSize / 1.4) - } - - @computed private get title(): TextWrap | undefined { - if (!this.props.legendTitle) return undefined - return new TextWrap({ - maxWidth: this.maxLegendWidth, - 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 private get series(): SizedLegendSeries[] { - const { fontSize, rectSize, rectPadding, titleHeight, lineHeight } = - 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}` - } - 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.props.legendX ?? 0 - } - - @computed get legendY(): number { - return this.props.legendY ?? 0 - } - - renderLabels(): React.ReactElement { - const { series, rectSize, rectPadding } = this - const { focusColors } = this.props - - 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 { series, rectSize, rectPadding } = this - const { activeColors = [] } = this.props - - 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, lineHeight } = this - const { onLegendClick, onLegendMouseOver, onLegendMouseLeave } = - this.props - 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.props.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 0000000000..8ee3a10742 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegendComponent.tsx @@ -0,0 +1,174 @@ +import React from "react" + +import { isEmpty, makeIdForHumanConsumption } from "@ourworldindata/utils" +import { VerticalColorLegend } from "./VerticalColorLegend" + +export function VerticalColorLegendComponent({ + state, + x = 0, + y = 0, + eventListeners, +}: { + state: VerticalColorLegend + x?: number + y?: number + eventListeners?: { + onLegendMouseOver?: (color: string) => void + onLegendClick?: (color: string) => void + onLegendMouseLeave?: () => void + } +}): React.ReactElement { + return ( + + {state.title && + state.title.render(x, y, { + textProps: { + fontWeight: 700, + }, + })} + + + {eventListeners && !isEmpty(eventListeners) && ( + + )} + + ) +} + +function Labels({ + x, + y, + state, +}: { + x: number + y: number + state: VerticalColorLegend +}): React.ReactElement { + return ( + + {state.series.map((series) => { + const isFocus = + state.props.focusColors?.includes(series.color) ?? false + + const textX = x + state.rectSize + state.rectPadding + const textY = y + series.yOffset + + return ( + + {series.textWrap.render( + textX, + textY, + isFocus + ? { + textProps: { + style: { fontWeight: "bold" }, + }, + } + : undefined + )} + + ) + })} + + ) +} + +function Swatches({ + x, + y, + state, +}: { + x: number + y: number + state: VerticalColorLegend +}): React.ReactElement { + return ( + + {state.series.map((series) => { + const isActive = state.props.activeColors?.includes( + series.color + ) + + const textX = x + state.rectSize + state.rectPadding + const textY = y + series.yOffset + + const renderedTextPosition = + series.textWrap.getPositionForSvgRendering(textX, textY) + + return ( + + ) + })} + + ) +} + +function InteractiveElement({ + x, + y, + state, + eventListeners, +}: { + x: number + y: number + state: VerticalColorLegend + eventListeners?: { + onLegendMouseOver?: (color: string) => void + onLegendClick?: (color: string) => void + onLegendMouseLeave?: () => void + } +}): React.ReactElement { + const { onLegendMouseOver, onLegendMouseLeave, onLegendClick } = + eventListeners ?? {} + return ( + + {state.series.map((series) => { + const mouseOver = onLegendMouseOver + ? (): void => onLegendMouseOver(series.color) + : undefined + const mouseLeave = onLegendMouseLeave + const click = onLegendClick + ? (): void => onLegendClick(series.color) + : undefined + + const cursor = click ? "pointer" : "default" + + return ( + + + + ) + })} + + ) +}