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 {