{
])
})
})
-
-describe("firstLineOffset", () => {
- it("should offset the first line if requested", () => {
- const text = "an example line"
- const props = { text, maxWidth: 100, fontSize: FONT_SIZE }
-
- const wrapWithoutOffset = new TextWrap(props)
- const wrapWithOffset = new TextWrap({
- ...props,
- firstLineOffset: 50,
- })
-
- expect(wrapWithoutOffset.lines.map((l) => l.text)).toEqual([
- "an example",
- "line",
- ])
- expect(wrapWithOffset.lines.map((l) => l.text)).toEqual([
- "an",
- "example line",
- ])
- })
-
- it("should break into a new line even if the first line would end up being empty", () => {
- const text = "a-very-long-word"
- const props = { text, maxWidth: 100, fontSize: FONT_SIZE }
-
- const wrapWithoutOffset = new TextWrap(props)
- const wrapWithOffset = new TextWrap({
- ...props,
- firstLineOffset: 50,
- })
-
- expect(wrapWithoutOffset.lines.map((l) => l.text)).toEqual([
- "a-very-long-word",
- ])
- expect(wrapWithOffset.lines.map((l) => l.text)).toEqual([
- "",
- "a-very-long-word",
- ])
- })
-
- it("should break into a new line if firstLineOffset > maxWidth", () => {
- const text = "an example line"
- const wrap = new TextWrap({
- text,
- maxWidth: 100,
- fontSize: FONT_SIZE,
- firstLineOffset: 150,
- })
-
- expect(wrap.lines.map((l) => l.text)).toEqual([
- "",
- "an example",
- "line",
- ])
- })
-})
diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
index 7912d62acc..4cc16e188a 100644
--- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
+++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
@@ -11,7 +11,6 @@ interface TextWrapProps {
lineHeight?: number
fontSize: FontSize
fontWeight?: number
- firstLineOffset?: number
separators?: string[]
rawHtml?: boolean
}
@@ -27,6 +26,11 @@ interface OpenHtmlTag {
fullTag: string // e.g. "
"
}
+interface SVGRenderProps {
+ textProps?: React.SVGProps
+ id?: string
+}
+
const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)( [^<>]*)?>/g
function startsWithNewline(text: string): boolean {
@@ -81,9 +85,6 @@ export class TextWrap {
@computed get separators(): string[] {
return this.props.separators ?? [" "]
}
- @computed get firstLineOffset(): number {
- return this.props.firstLineOffset ?? 0
- }
// We need to take care that HTML tags are not split across lines.
// Instead, we want every line to have opening and closing tags for all tags that appear.
@@ -152,27 +153,15 @@ export class TextWrap {
? stripHTML(joinFragments(nextLine))
: joinFragments(nextLine)
- let nextBounds = Bounds.forText(text, {
+ const nextBounds = Bounds.forText(text, {
fontSize,
fontWeight,
})
- // add offset to the first line if given
- if (lines.length === 0 && this.firstLineOffset) {
- nextBounds = nextBounds.set({
- width: nextBounds.width + this.firstLineOffset,
- })
- }
-
- // start a new line before the current word if the max-width is exceeded.
- // usually breaking into a new line doesn't make sense if the current line is empty.
- // but if the first line is offset (which is useful in grouped text wraps),
- // we might want to break into a new line anyway.
- const startNewLineBeforeWord =
- nextBounds.width + 10 > maxWidth &&
- (line.length >= 1 || this.firstLineOffset)
-
- if (startsWithNewline(fragment.text) || startNewLineBeforeWord) {
+ if (
+ startsWithNewline(fragment.text) ||
+ (nextBounds.width + 10 > maxWidth && line.length >= 1)
+ ) {
// Introduce a newline _before_ this word
lines.push({
text: joinFragments(line),
@@ -241,6 +230,24 @@ export class TextWrap {
}
}
+ getPositionForSvgRendering(x: number, y: number): [number, number] {
+ const { lines, fontSize, lineHeight } = this
+
+ // Magic number set through experimentation.
+ // The HTML and SVG renderers need to position lines identically.
+ // This number was tweaked until the overlaid HTML and SVG outputs
+ // overlap (see storybook of this component).
+ const HEIGHT_CORRECTION_FACTOR = 0.74
+
+ const textHeight = max(lines.map((line) => line.height)) ?? 0
+ const correctedTextHeight = textHeight * HEIGHT_CORRECTION_FACTOR
+ const containerHeight = lineHeight * fontSize
+ const yOffset =
+ y + (containerHeight - (containerHeight - correctedTextHeight) / 2)
+
+ return [x, yOffset]
+ }
+
renderHTML(): React.ReactElement | null {
const { props, lines } = this
@@ -269,40 +276,12 @@ export class TextWrap {
)
}
- getPositionForSvgRendering(x: number, y: number): [number, number] {
- const { lines, fontSize, lineHeight } = this
-
- // Magic number set through experimentation.
- // The HTML and SVG renderers need to position lines identically.
- // This number was tweaked until the overlaid HTML and SVG outputs
- // overlap (see storybook of this component).
- const HEIGHT_CORRECTION_FACTOR = 0.74
-
- const textHeight = max(lines.map((line) => line.height)) ?? 0
- const correctedTextHeight = textHeight * HEIGHT_CORRECTION_FACTOR
- const containerHeight = lineHeight * fontSize
- const yOffset =
- y + (containerHeight - (containerHeight - correctedTextHeight) / 2)
-
- return [x, yOffset]
- }
-
- render(
+ renderSVG(
x: number,
y: number,
- {
- textProps,
- id,
- }: { textProps?: React.SVGProps; id?: string } = {}
+ options: SVGRenderProps = {}
): React.ReactElement {
- const {
- props,
- lines,
- fontSize,
- fontWeight,
- lineHeight,
- firstLineOffset,
- } = this
+ const { props, lines, fontSize, fontWeight, lineHeight } = this
if (lines.length === 0) return <>>
@@ -310,15 +289,15 @@ export class TextWrap {
return (
{lines.map((line, i) => {
- const x = correctedX + (i === 0 ? firstLineOffset : 0)
+ const x = correctedX
const y = correctedY + lineHeight * fontSize * i
if (props.rawHtml)
diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts
deleted file mode 100644
index 7bb725b415..0000000000
--- a/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-#! /usr/bin/env jest
-
-import { TextWrap } from "./TextWrap"
-import { TextWrapGroup } from "./TextWrapGroup"
-
-const FONT_SIZE = 14
-const TEXT = "Lower middle-income countries"
-const MAX_WIDTH = 150
-
-const textWrap = new TextWrap({
- text: TEXT,
- maxWidth: MAX_WIDTH,
- fontSize: FONT_SIZE,
-})
-
-it("should work like TextWrap for a single fragment", () => {
- const textWrapGroup = new TextWrapGroup({
- fragments: [{ text: TEXT }],
- maxWidth: MAX_WIDTH,
- fontSize: FONT_SIZE,
- })
-
- const firstTextWrap = textWrapGroup.textWraps[0]
- expect(firstTextWrap.text).toEqual(textWrap.text)
- expect(firstTextWrap.width).toEqual(textWrap.width)
- expect(firstTextWrap.height).toEqual(textWrap.height)
- expect(firstTextWrap.lines).toEqual(textWrap.lines)
-})
-
-it("should place fragments in-line if there is space", () => {
- const textWrapGroup = new TextWrapGroup({
- fragments: [{ text: TEXT }, { text: "30 million" }],
- maxWidth: MAX_WIDTH,
- fontSize: FONT_SIZE,
- })
-
- expect(textWrapGroup.text).toEqual([TEXT, "30 million"].join(" "))
- expect(textWrapGroup.height).toEqual(textWrap.height)
-})
-
-it("should place the second segment in a new line if preferred", () => {
- const maxWidth = 250
- const textWrapGroup = new TextWrapGroup({
- fragments: [
- { text: TEXT },
- { text: "30 million", newLine: "avoid-wrap" },
- ],
- maxWidth,
- fontSize: FONT_SIZE,
- })
-
- // 30 million should be placed in a new line, thus the group's height
- // should be greater than the textWrap's height
- expect(textWrapGroup.height).toBeGreaterThan(
- new TextWrap({
- text: TEXT,
- maxWidth,
- fontSize: FONT_SIZE,
- }).height
- )
-})
-
-it("should place the second segment in the same line if possible", () => {
- const maxWidth = 1000
- const textWrapGroup = new TextWrapGroup({
- fragments: [
- { text: TEXT },
- { text: "30 million", newLine: "avoid-wrap" },
- ],
- maxWidth,
- fontSize: FONT_SIZE,
- })
-
- // since the max width is large, "30 million" fits into the same line
- // as the text of the first fragmemt
- expect(textWrapGroup.height).toEqual(
- new TextWrap({
- text: TEXT,
- maxWidth,
- fontSize: FONT_SIZE,
- }).height
- )
-})
-
-it("should place the second segment in the same line if specified", () => {
- const maxWidth = 1000
- const textWrapGroup = new TextWrapGroup({
- fragments: [{ text: TEXT }, { text: "30 million", newLine: "always" }],
- maxWidth,
- fontSize: FONT_SIZE,
- })
-
- // "30 million" should be placed in a new line since newLine is set to 'always'
- expect(textWrapGroup.height).toBeGreaterThan(
- new TextWrap({
- text: TEXT,
- maxWidth,
- fontSize: FONT_SIZE,
- }).height
- )
-})
-
-it("should use all available space when one fragment exceeds the given max width", () => {
- const maxWidth = 150
- const textWrap = new TextWrap({
- text: "Long-word-that-can't-be-broken-up more words",
- maxWidth,
- fontSize: FONT_SIZE,
- })
- const textWrapGroup = new TextWrapGroup({
- fragments: [
- { text: "Long-word-that-can't-be-broken-up more words" },
- { text: "30 million" },
- ],
- maxWidth,
- fontSize: FONT_SIZE,
- })
- expect(textWrap.width).toBeGreaterThan(maxWidth)
- expect(textWrapGroup.maxWidth).toEqual(textWrap.width)
-})
-
-it("should place very long words in a separate line", () => {
- const maxWidth = 150
- const textWrapGroup = new TextWrapGroup({
- fragments: [
- { text: "30 million" },
- { text: "Long-word-that-can't-be-broken-up" },
- ],
- maxWidth,
- fontSize: FONT_SIZE,
- })
- expect(textWrapGroup.lines.length).toEqual(2)
-
- const placedTextWrapOffsets = textWrapGroup.placedTextWraps.map(
- ({ yOffset }) => yOffset
- )
- const lineOffsets = textWrapGroup.lines.map(({ yOffset }) => yOffset)
- expect(placedTextWrapOffsets).toEqual([0, 0])
- expect(lineOffsets).toEqual([0, textWrapGroup.lineHeight * FONT_SIZE])
-})
diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.tsx
deleted file mode 100644
index d540132ed7..0000000000
--- a/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-import * as React from "react"
-import { computed } from "mobx"
-import { TextWrap } from "./TextWrap"
-import { splitIntoFragments } from "./TextWrapUtils"
-import { Bounds, last, max } from "@ourworldindata/utils"
-import { Halo } from "../Halo/Halo"
-
-interface TextWrapFragment {
- text: string
- fontWeight?: number
- // specifies the wrapping behavior of the fragment (only applies to the
- // second, third,... fragments but not the first one)
- // - "continue-line" places the fragment in the same line if possible (default)
- // - "always" places the fragment in a new line in all cases
- // - "avoid-wrap" places the fragment in a new line only if the fragment would wrap otherwise
- newLine?: "continue-line" | "always" | "avoid-wrap"
-}
-
-interface PlacedTextWrap {
- textWrap: TextWrap
- yOffset: number
-}
-
-interface TextWrapGroupProps {
- fragments: TextWrapFragment[]
- maxWidth: number
- lineHeight?: number
- fontSize: number
- fontWeight?: number
-}
-
-export class TextWrapGroup {
- props: TextWrapGroupProps
- constructor(props: TextWrapGroupProps) {
- this.props = props
- }
-
- @computed get lineHeight(): number {
- return this.props.lineHeight ?? 1.1
- }
-
- @computed get fontSize(): number {
- return this.props.fontSize
- }
-
- @computed get fontWeight(): number | undefined {
- return this.props.fontWeight
- }
-
- @computed get text(): string {
- return this.props.fragments.map((fragment) => fragment.text).join(" ")
- }
-
- @computed get maxWidth(): number {
- const wordWidths = this.props.fragments.flatMap((fragment) =>
- splitIntoFragments(fragment.text).map(
- ({ text }) =>
- Bounds.forText(text, {
- fontSize: this.fontSize,
- fontWeight: fragment.fontWeight ?? this.fontWeight,
- }).width
- )
- )
- return max([...wordWidths, this.props.maxWidth]) ?? Infinity
- }
-
- private makeTextWrapForFragment(
- fragment: TextWrapFragment,
- offset = 0
- ): TextWrap {
- return new TextWrap({
- text: fragment.text,
- maxWidth: this.maxWidth,
- lineHeight: this.lineHeight,
- fontSize: this.fontSize,
- fontWeight: fragment.fontWeight ?? this.fontWeight,
- firstLineOffset: offset,
- })
- }
-
- @computed private get whitespaceWidth(): number {
- return Bounds.forText(" ", { fontSize: this.fontSize }).width
- }
-
- private getOffsetOfNextTextWrap(textWrap: TextWrap): number {
- return textWrap.lastLineWidth + this.whitespaceWidth
- }
-
- private placeTextWrapIntoNewLine(
- fragment: TextWrapFragment,
- previousPlacedTextWrap: PlacedTextWrap
- ): PlacedTextWrap {
- const { textWrap: lastTextWrap, yOffset: lastYOffset } =
- previousPlacedTextWrap
-
- const textWrap = this.makeTextWrapForFragment(fragment)
- const yOffset = lastYOffset + lastTextWrap.height
-
- return { textWrap, yOffset }
- }
-
- private placeTextWrapIntoTheSameLine(
- fragment: TextWrapFragment,
- previousPlacedTextWrap: PlacedTextWrap
- ): PlacedTextWrap {
- const { textWrap: lastTextWrap, yOffset: lastYOffset } =
- previousPlacedTextWrap
-
- const xOffset = this.getOffsetOfNextTextWrap(lastTextWrap)
- const textWrap = this.makeTextWrapForFragment(fragment, xOffset)
-
- // if the text wrap is placed in the same line, we need to
- // be careful not to double count the height of the first line
- const heightWithoutFirstLine =
- (lastTextWrap.lineCount - 1) * lastTextWrap.singleLineHeight
- const yOffset = lastYOffset + heightWithoutFirstLine
-
- return { textWrap, yOffset }
- }
-
- private placeTextWrapIntoTheSameLineIfNotWrapping(
- fragment: TextWrapFragment,
- previousPlacedTextWrap: PlacedTextWrap
- ): PlacedTextWrap {
- const { textWrap: lastTextWrap } = previousPlacedTextWrap
-
- // try to place text wrap in the same line with the given offset
- const xOffset = this.getOffsetOfNextTextWrap(lastTextWrap)
- const textWrap = this.makeTextWrapForFragment(fragment, xOffset)
-
- const lineCount = textWrap.lines.filter((text) => text).length
- if (lineCount > 1) {
- // if the text is wrapping, break into a new line instead
- return this.placeTextWrapIntoNewLine(
- fragment,
- previousPlacedTextWrap
- )
- } else {
- // else, place the text wrap in the same line
- return this.placeTextWrapIntoTheSameLine(
- fragment,
- previousPlacedTextWrap
- )
- }
- }
-
- private placeTextWrap(
- fragment: TextWrapFragment,
- previousPlacedTextWrap: PlacedTextWrap
- ): PlacedTextWrap {
- const newLine = fragment.newLine ?? "continue-line"
- switch (newLine) {
- case "always":
- return this.placeTextWrapIntoNewLine(
- fragment,
- previousPlacedTextWrap
- )
- case "continue-line":
- return this.placeTextWrapIntoTheSameLine(
- fragment,
- previousPlacedTextWrap
- )
- case "avoid-wrap":
- return this.placeTextWrapIntoTheSameLineIfNotWrapping(
- fragment,
- previousPlacedTextWrap
- )
- }
- }
-
- @computed get placedTextWraps(): PlacedTextWrap[] {
- const { fragments } = this.props
- if (fragments.length === 0) return []
-
- const firstTextWrap = this.makeTextWrapForFragment(fragments[0])
- const textWraps: PlacedTextWrap[] = [
- { textWrap: firstTextWrap, yOffset: 0 },
- ]
-
- for (let i = 1; i < fragments.length; i++) {
- const fragment = fragments[i]
- const previousPlacedTextWrap = textWraps[i - 1]
- textWraps.push(this.placeTextWrap(fragment, previousPlacedTextWrap))
- }
-
- return textWraps
- }
-
- @computed get textWraps(): TextWrap[] {
- return this.placedTextWraps.map(({ textWrap }) => textWrap)
- }
-
- @computed get height(): number {
- if (this.placedTextWraps.length === 0) return 0
- const { textWrap, yOffset } = last(this.placedTextWraps)!
- return yOffset + textWrap.height
- }
-
- @computed get singleLineHeight(): number {
- if (this.textWraps.length === 0) return 0
- return this.textWraps[0].singleLineHeight
- }
-
- @computed get width(): number {
- return max(this.textWraps.map((textWrap) => textWrap.width)) ?? 0
- }
-
- // split concatenated fragments into lines for rendering. a line may have
- // multiple fragments since each fragment comes with its own style and
- // is therefore rendered into a separate tspan.
- @computed get lines(): {
- fragments: { text: string; textWrap: TextWrap }[]
- yOffset: number
- }[] {
- const lines = []
- for (const { textWrap, yOffset } of this.placedTextWraps) {
- for (let i = 0; i < textWrap.lineCount; i++) {
- const line = textWrap.lines[i]
- const isFirstLineInTextWrap = i === 0
-
- // don't render empty lines
- if (!line.text) continue
-
- const fragment = {
- text: line.text,
- textWrap,
- }
-
- const lastLine = last(lines)
- if (
- isFirstLineInTextWrap &&
- textWrap.firstLineOffset > 0 &&
- lastLine
- ) {
- // if the current line is offset, add it to the previous line
- lastLine.fragments.push(fragment)
- } else {
- // else, push a new line
- lines.push({
- fragments: [fragment],
- yOffset: yOffset + i * textWrap.singleLineHeight,
- })
- }
- }
- }
-
- return lines
- }
-
- render(
- x: number,
- y: number,
- {
- showTextOutline,
- textOutlineColor,
- textProps,
- }: {
- showTextOutline?: boolean
- textOutlineColor?: string
- textProps?: React.SVGProps
- } = {}
- ): React.ReactElement {
- // Alternatively, we could render each TextWrap one by one. That would
- // give us a good but not pixel-perfect result since the text
- // measurements are not 100% accurate. To avoid inconsistent spacing
- // between text wraps, we split the text into lines and render
- // the different styles as tspans within the same text element.
- return (
- <>
- {this.lines.map((line) => {
- const key = line.yOffset.toString()
- const [textX, textY] =
- line.fragments[0].textWrap.getPositionForSvgRendering(
- x,
- y
- )
- return (
-
-
- {line.fragments.map((fragment, index) => (
-
- {index === 0 ? "" : " "}
- {fragment.text}
-
- ))}
-
-
- )
- })}
- >
- )
- }
-}
diff --git a/packages/@ourworldindata/components/src/index.ts b/packages/@ourworldindata/components/src/index.ts
index cbe2a89077..ab0603ca19 100644
--- a/packages/@ourworldindata/components/src/index.ts
+++ b/packages/@ourworldindata/components/src/index.ts
@@ -1,5 +1,4 @@
export { TextWrap, shortenForTargetWidth } from "./TextWrap/TextWrap.js"
-export { TextWrapGroup } from "./TextWrap/TextWrapGroup.js"
export {
MarkdownTextWrap,
diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx
index ece79baf28..3e2d2e64cf 100644
--- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx
+++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx
@@ -409,7 +409,7 @@ export class DiscreteBarChart
return (
series.label && (
- {series.label.render(
+ {series.label.renderSVG(
series.entityLabelX,
series.barY - series.label.height / 2,
{ textProps: style }
diff --git a/packages/@ourworldindata/grapher/src/footer/Footer.tsx b/packages/@ourworldindata/grapher/src/footer/Footer.tsx
index d2132d46b0..7cd3a0aee3 100644
--- a/packages/@ourworldindata/grapher/src/footer/Footer.tsx
+++ b/packages/@ourworldindata/grapher/src/footer/Footer.tsx
@@ -777,12 +777,12 @@ export class StaticFooter extends Footer {
}
)}
{showLicenseNextToSources
- ? licenseAndOriginUrl.render(
+ ? licenseAndOriginUrl.renderSVG(
targetX + maxWidth - licenseAndOriginUrl.width,
targetY,
{ id: makeIdForHumanConsumption("origin-url") }
)
- : licenseAndOriginUrl.render(
+ : licenseAndOriginUrl.renderSVG(
targetX,
targetY +
sources.height +
diff --git a/packages/@ourworldindata/grapher/src/header/Header.tsx b/packages/@ourworldindata/grapher/src/header/Header.tsx
index f961a6d1b0..1febb44ffe 100644
--- a/packages/@ourworldindata/grapher/src/header/Header.tsx
+++ b/packages/@ourworldindata/grapher/src/header/Header.tsx
@@ -315,7 +315,7 @@ export class StaticHeader extends Header {
target="_blank"
rel="noopener"
>
- {title.render(x, y, {
+ {title.renderSVG(x, y, {
textProps: { fill: GRAPHER_DARK_TEXT },
})}
diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx
index 069a40c154..f0fdfd8707 100644
--- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx
+++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx
@@ -642,7 +642,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend {
))}
- {this.legendTitle?.render(
+ {this.legendTitle?.renderSVG(
this.x,
// Align legend title baseline with bottom of color bins
this.numericLegendY +
diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx
index 8a87922921..24407890d8 100644
--- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx
+++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx
@@ -31,7 +31,7 @@ import { select } from "d3-selection"
import { easeLinear } from "d3-ease"
import { DualAxisComponent } from "../axis/AxisViews"
import { DualAxis, HorizontalAxis, VerticalAxis } from "../axis/Axis"
-import { LineLegend, LineLabelSeries } from "../lineLegend/LineLegend"
+import { LineLegend } from "../lineLegend/LineLegend"
import { ComparisonLine } from "../scatterCharts/ComparisonLine"
import { TooltipFooterIcon } from "../tooltip/TooltipProps.js"
import {
@@ -115,6 +115,7 @@ import {
getSeriesName,
} from "./LineChartHelpers"
import { FocusArray } from "../focus/FocusArray.js"
+import { LineLabelSeries } from "../lineLegend/LineLegendTypes"
const LINE_CHART_CLASS_NAME = "LineChart"
diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx
index c2be72ef40..6913952d58 100755
--- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx
+++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx
@@ -2,8 +2,9 @@
import { PartialBy } from "@ourworldindata/utils"
import { AxisConfig } from "../axis/AxisConfig"
-import { LineLabelSeries, LineLegend } from "./LineLegend"
+import { LineLegend } from "./LineLegend"
import { LEGEND_ITEM_MIN_SPACING } from "./LineLegendConstants"
+import { LineLabelSeries } from "./LineLegendTypes"
const makeAxis = ({
min = 0,
diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx
index dbcc3d0127..9b2431c04c 100644
--- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx
+++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx
@@ -11,19 +11,17 @@ import {
excludeUndefined,
sumBy,
} from "@ourworldindata/utils"
-import { TextWrap, TextWrapGroup, Halo } from "@ourworldindata/components"
+import { TextWrap, Halo, MarkdownTextWrap } from "@ourworldindata/components"
import { computed } from "mobx"
import { observer } from "mobx-react"
import { VerticalAxis } from "../axis/Axis"
import {
Color,
EntityName,
- InteractionState,
SeriesName,
VerticalAlign,
} from "@ourworldindata/types"
import { BASE_FONT_SIZE, GRAPHER_FONT_SCALE_12 } from "../core/GrapherConstants"
-import { ChartSeries } from "../chart/ChartInterface"
import { darkenColorForText } from "../color/ColorUtils"
import { AxisConfig } from "../axis/AxisConfig.js"
import { GRAPHER_BACKGROUND_DEFAULT, GRAY_30 } from "../color/ColorConstants"
@@ -40,34 +38,7 @@ import {
NON_FOCUSED_TEXT_COLOR,
} from "./LineLegendConstants.js"
import { getSeriesKey } from "./LineLegendHelpers"
-
-export interface LineLabelSeries extends ChartSeries {
- label: string
- yValue: number
- annotation?: string
- formattedValue?: string
- placeFormattedValueInNewLine?: boolean
- yRange?: [number, number]
- hover?: InteractionState
- focus?: InteractionState
-}
-
-interface SizedSeries extends LineLabelSeries {
- textWrap: TextWrap | TextWrapGroup
- annotationTextWrap?: TextWrap
- width: number
- height: number
- fontWeight?: number
-}
-
-interface PlacedSeries extends SizedSeries {
- origBounds: Bounds
- bounds: Bounds
- repositions: number
- level: number
- totalLevels: number
- midY: number
-}
+import { LineLabelSeries, PlacedSeries, SizedSeries } from "./LineLegendTypes"
function groupBounds(group: PlacedSeries[]): Bounds {
const first = group[0]
@@ -162,29 +133,19 @@ class LineLabels extends React.Component<{
textAnchor: this.anchor,
}
- return series.textWrap instanceof TextWrap ? (
+ return (
- {series.textWrap.render(labelText.x, labelText.y, {
- textProps: {
- ...textProps,
- // might override the textWrap's fontWeight
- fontWeight: series.fontWeight,
- },
- })}
+ {series.textWrapForRendering.renderSVG(
+ labelText.x,
+ labelText.y,
+ { textProps }
+ )}
- ) : (
-
- {series.textWrap.render(labelText.x, labelText.y, {
- showTextOutline: this.showTextOutline,
- textOutlineColor: this.textOutlineColor,
- textProps,
- })}
-
)
})}
@@ -207,7 +168,7 @@ class LineLabels extends React.Component<{
show={this.showTextOutline}
outlineColor={this.textOutlineColor}
>
- {series.annotationTextWrap.render(
+ {series.annotationTextWrap.renderSVG(
labelText.x,
labelText.y +
series.textWrap.height +
@@ -397,39 +358,40 @@ export class LineLegend extends React.Component
{
}
private makeLabelTextWrap(
- series: LineLabelSeries
- ): TextWrap | TextWrapGroup {
+ series: LineLabelSeries,
+ { fontWeights }: { fontWeights: { label: number; value?: number } }
+ ): TextWrap | MarkdownTextWrap {
if (!series.formattedValue) {
return new TextWrap({
text: series.label,
maxWidth: this.textMaxWidth,
fontSize: this.fontSize,
- // using the actual font weight here would lead to a jumpy layout
- // when focusing/unfocusing a series since focused series are
- // bolded and the computed text width depends on the text's font weight.
- // that's why we always use bold labels to comupte the layout,
- // but might render them later using a regular font weight.
- fontWeight: 700,
+ fontWeight: fontWeights?.label,
})
}
- // text label fragment
- const textLabel = { text: series.label, fontWeight: 700 }
+ const isTextLabelBold = (fontWeights?.label ?? 400) >= 700
+ const isValueLabelBold = (fontWeights?.value ?? 400) >= 700
- // value label fragment
- const newLine = series.placeFormattedValueInNewLine
- ? "always"
- : "avoid-wrap"
+ // text label fragments
+ const textLabel = { text: series.label, bold: isTextLabelBold }
const valueLabel = {
text: series.formattedValue,
- fontWeight: 400,
- newLine,
+ bold: isValueLabelBold,
}
- return new TextWrapGroup({
- fragments: [textLabel, valueLabel],
- maxWidth: this.textMaxWidth,
- fontSize: this.fontSize,
+ const newLine = series.placeFormattedValueInNewLine
+ ? "always"
+ : "avoid-wrap"
+
+ return MarkdownTextWrap.fromFragments({
+ main: textLabel,
+ secondary: valueLabel,
+ newLine,
+ textWrapProps: {
+ maxWidth: this.textMaxWidth,
+ fontSize: this.fontSize,
+ },
})
}
@@ -449,14 +411,6 @@ export class LineLegend extends React.Component {
@computed.struct get sizedSeries(): SizedSeries[] {
const { fontWeight: globalFontWeight } = this
return this.props.series.map((series) => {
- const textWrap = this.makeLabelTextWrap(series)
- const annotationTextWrap = this.makeAnnotationTextWrap(series)
-
- const annotationWidth = annotationTextWrap?.width ?? 0
- const annotationHeight = annotationTextWrap
- ? ANNOTATION_PADDING + annotationTextWrap.height
- : 0
-
// font weight priority:
// series focus state > presense of value label > globally set font weight
const activeFontWeight = series.focus?.active ? 700 : undefined
@@ -464,13 +418,31 @@ export class LineLegend extends React.Component {
const fontWeight =
activeFontWeight ?? seriesFontWeight ?? globalFontWeight
+ // line labels might be focused/unfocused, which affects their font weight.
+ // if we used the actual font weight for measuring the text width,
+ // the layout would be jumpy when focusing/unfocusing a series.
+ const fontWeightsForMeasuring = { label: 700, value: 700 }
+ const fontWeightsForRendering = { label: fontWeight, value: 400 }
+ const textWrap = this.makeLabelTextWrap(series, {
+ fontWeights: fontWeightsForMeasuring,
+ })
+ const textWrapForRendering = this.makeLabelTextWrap(series, {
+ fontWeights: fontWeightsForRendering,
+ })
+
+ const annotationTextWrap = this.makeAnnotationTextWrap(series)
+ const annotationWidth = annotationTextWrap?.width ?? 0
+ const annotationHeight = annotationTextWrap
+ ? ANNOTATION_PADDING + annotationTextWrap.height
+ : 0
+
return {
...series,
textWrap,
+ textWrapForRendering,
annotationTextWrap,
width: Math.max(textWrap.width, annotationWidth),
height: textWrap.height + annotationHeight,
- fontWeight,
}
})
}
diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts
index 59905fa108..1dbbd3380b 100644
--- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts
+++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts
@@ -1,4 +1,4 @@
-import { TextWrap, TextWrapGroup } from "@ourworldindata/components"
+import { MarkdownTextWrap, TextWrap } from "@ourworldindata/components"
import { Bounds, InteractionState } from "@ourworldindata/utils"
import { ChartSeries } from "../chart/ChartInterface"
@@ -14,7 +14,8 @@ export interface LineLabelSeries extends ChartSeries {
}
export interface SizedSeries extends LineLabelSeries {
- textWrap: TextWrap | TextWrapGroup
+ textWrap: TextWrap | MarkdownTextWrap
+ textWrapForRendering: TextWrap | MarkdownTextWrap
annotationTextWrap?: TextWrap
width: number
height: number
diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ConnectedScatterLegend.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ConnectedScatterLegend.tsx
index fc3e9726e3..b7f349b0b4 100644
--- a/packages/@ourworldindata/grapher/src/scatterCharts/ConnectedScatterLegend.tsx
+++ b/packages/@ourworldindata/grapher/src/scatterCharts/ConnectedScatterLegend.tsx
@@ -82,10 +82,10 @@ export class ConnectedScatterLegend {
fill="#fff"
opacity={0}
/>
- {startLabel.render(targetX, targetY, {
+ {startLabel.renderSVG(targetX, targetY, {
textProps: { fill: fontColor },
})}
- {endLabel.render(
+ {endLabel.renderSVG(
targetX + manager.sidebarWidth - endLabel.width,
targetY,
{ textProps: { fill: fontColor } }
diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx
index ff7c4a887a..c2744b6a10 100644
--- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx
+++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx
@@ -235,7 +235,7 @@ export class ScatterSizeLegend {
return (
{this.renderLegend(targetX, targetY)}
- {this.label.render(
+ {this.label.renderSVG(
centerX,
targetY + this.legendSize + LEGEND_PADDING,
{
@@ -245,7 +245,7 @@ export class ScatterSizeLegend {
},
}
)}
- {this.title.render(
+ {this.title.renderSVG(
centerX,
targetY +
this.legendSize +
diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx
index b713209ebf..180c945701 100644
--- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx
+++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx
@@ -64,11 +64,7 @@ import { NoDataSection } from "../scatterCharts/NoDataSection"
import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner"
import { ColorScheme } from "../color/ColorScheme"
import { ColorSchemes } from "../color/ColorSchemes"
-import {
- LineLabelSeries,
- LineLegend,
- LineLegendProps,
-} from "../lineLegend/LineLegend"
+import { LineLegend, LineLegendProps } from "../lineLegend/LineLegend"
import {
makeTooltipRoundingNotice,
makeTooltipToleranceNotice,
@@ -94,6 +90,7 @@ import {
GRAPHER_DARK_TEXT,
} from "../color/ColorConstants"
import { FocusArray } from "../focus/FocusArray"
+import { LineLabelSeries } from "../lineLegend/LineLegendTypes"
type SVGMouseOrTouchEvent =
| React.MouseEvent
diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx
index 538f16d368..6c2f28c103 100644
--- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx
+++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx
@@ -29,7 +29,7 @@ import {
import { observer } from "mobx-react"
import { DualAxisComponent } from "../axis/AxisViews"
import { DualAxis } from "../axis/Axis"
-import { LineLabelSeries, LineLegend } from "../lineLegend/LineLegend"
+import { LineLegend } from "../lineLegend/LineLegend"
import { NoDataModal } from "../noDataModal/NoDataModal"
import { TooltipFooterIcon } from "../tooltip/TooltipProps.js"
import {
@@ -57,6 +57,7 @@ import {
} from "../chart/ChartUtils"
import { bind } from "decko"
import { AxisConfig } from "../axis/AxisConfig.js"
+import { LineLabelSeries } from "../lineLegend/LineLegendTypes"
interface AreasProps extends React.SVGAttributes {
dualAxis: DualAxis
diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx
index fb806dc258..1691f01281 100644
--- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx
+++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx
@@ -673,7 +673,7 @@ export class StackedDiscreteBarChart
onMouseLeave={this.onEntityMouseLeave}
/>
))}
- {label.render(
+ {label.renderSVG(
yAxis.place(this.x0) - labelToBarPadding,
-label.height / 2,
{
diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx
index 00ce48d6aa..01956ae6ac 100644
--- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx
+++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx
@@ -154,7 +154,7 @@ export class VerticalColorLegend extends React.Component<{
return (
- {series.textWrap.render(
+ {series.textWrap.renderSVG(
textX,
textY,
isFocus
@@ -254,7 +254,7 @@ export class VerticalColorLegend extends React.Component<{
className="ScatterColorLegend clickable"
>
{this.title &&
- this.title.render(this.legendX, this.legendY, {
+ this.title.renderSVG(this.legendX, this.legendY, {
textProps: {
fontWeight: 700,
},