From 54f3e88d3da5bcd967ff4734d38ae01ab474acef Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Wed, 8 Jan 2025 10:40:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20use=20markdown=20for=20rendering?= =?UTF-8?q?=20grouped=20text=20wraps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MarkdownTextWrap/MarkdownTextWrap.test.ts | 84 +++++ .../src/MarkdownTextWrap/MarkdownTextWrap.tsx | 99 +++++- .../components/src/TextWrap/TextWrap.test.ts | 57 ---- .../components/src/TextWrap/TextWrap.tsx | 97 +++--- .../src/TextWrap/TextWrapGroup.test.ts | 140 -------- .../components/src/TextWrap/TextWrapGroup.tsx | 308 ------------------ .../@ourworldindata/components/src/index.ts | 1 - .../grapher/src/lineLegend/LineLegend.tsx | 59 ++-- .../grapher/src/lineLegend/LineLegendTypes.ts | 4 +- 9 files changed, 246 insertions(+), 603 deletions(-) delete mode 100644 packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts delete mode 100644 packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.tsx diff --git a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts index 05da1aaffe3..7a0efc9a1f0 100644 --- a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts +++ b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts @@ -166,3 +166,87 @@ describe("MarkdownTextWrap", () => { }) }) }) + +describe("fromFragments", () => { + const fontSize = 14 + + it("should place fragments in-line by default", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "Lower middle-income countries" }, + secondary: { text: "30 million" }, + textWrapProps: { + maxWidth: 500, + fontSize, + }, + }) + expect(textWrap.svgLines.length).toEqual(1) + expect(textWrap.htmlLines.length).toEqual(1) + }) + + it("should place the secondary text in a new line if requested", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "Lower middle-income countries" }, + secondary: { text: "30 million" }, + newLine: "always", + textWrapProps: { + maxWidth: 1000, + fontSize, + }, + }) + expect(textWrap.svgLines.length).toEqual(2) + expect(textWrap.htmlLines.length).toEqual(2) + }) + + it("should place the secondary text in a new line if line breaks should be avoided", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "Lower middle-income countries" }, + secondary: { text: "30 million" }, + newLine: "avoid-wrap", + textWrapProps: { + maxWidth: 250, + fontSize, + }, + }) + expect(textWrap.svgLines.length).toEqual(2) + expect(textWrap.htmlLines.length).toEqual(2) + }) + + it("should place the secondary text in the same line if possible", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "Lower middle-income countries" }, + secondary: { text: "30 million" }, + newLine: "avoid-wrap", + textWrapProps: { + maxWidth: 1000, + fontSize, + }, + }) + expect(textWrap.svgLines.length).toEqual(1) + expect(textWrap.htmlLines.length).toEqual(1) + }) + + it("should use all available space when one fragment exceeds the given max width", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "Long-word-that-can't-be-broken-up more words" }, + secondary: { text: "30 million" }, + textWrapProps: { + maxWidth: 150, + fontSize, + }, + }) + expect(textWrap.width).toBeGreaterThan(150) + }) + + it("should place very long words in a separate line", () => { + const textWrap = MarkdownTextWrap.fromFragments({ + main: { text: "30 million" }, + secondary: { text: "Long-word-that-can't-be-broken-up" }, + textWrapProps: { + maxWidth: 150, + fontSize, + }, + }) + expect(textWrap.svgLines.length).toEqual(2) + expect(textWrap.htmlLines.length).toEqual(2) + }) +}) diff --git a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.tsx b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.tsx index ff1bfdf533a..076208059c4 100644 --- a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.tsx +++ b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.tsx @@ -507,18 +507,83 @@ export const sumTextWrapHeights = ( sum(elements.map((element) => element.height)) + (elements.length - 1) * spacer -type MarkdownTextWrapProps = { - text: string - fontSize: number +type MarkdownTextWrapOptions = { + maxWidth?: number fontFamily?: FontFamily + fontSize: number fontWeight?: number lineHeight?: number - maxWidth?: number style?: CSSProperties detailsOrderedByReference?: string[] } +type MarkdownTextWrapProps = { text: string } & MarkdownTextWrapOptions + +type TextFragment = { text: string; bold?: boolean } + export class MarkdownTextWrap extends React.Component { + static fromFragments({ + main, + secondary, + newLine = "continue-line", + textWrapProps, + }: { + main: TextFragment + secondary: TextFragment + newLine?: "continue-line" | "always" | "avoid-wrap" + textWrapProps: Omit + }) { + const mainMarkdownText = maybeBoldMarkdownText(main) + const secondaryMarkdownText = maybeBoldMarkdownText(secondary) + + const combinedTextContinued = [ + mainMarkdownText, + secondaryMarkdownText, + ].join(" ") + const combinedTextNewLine = [ + mainMarkdownText, + secondaryMarkdownText, + ].join("\n") + + if (newLine === "always") { + return new MarkdownTextWrap({ + text: combinedTextNewLine, + ...textWrapProps, + }) + } + + if (newLine === "continue-line") { + return new MarkdownTextWrap({ + text: combinedTextContinued, + ...textWrapProps, + }) + } + + // if newLine is set to 'avoid-wrap', we first try to fit the secondary text + // on the same line as the main text. If it doesn't fit, we place it on a new line. + + const mainTextWrap = new MarkdownTextWrap({ ...main, ...textWrapProps }) + const secondaryTextWrap = new MarkdownTextWrap({ + text: secondaryMarkdownText, + ...textWrapProps, + maxWidth: mainTextWrap.maxWidth - mainTextWrap.lastLineWidth, + }) + + const secondaryTextFitsOnSameLine = + secondaryTextWrap.svgLines.length === 1 + if (secondaryTextFitsOnSameLine) { + return new MarkdownTextWrap({ + text: combinedTextContinued, + ...textWrapProps, + }) + } else { + return new MarkdownTextWrap({ + text: combinedTextNewLine, + ...textWrapProps, + }) + } + } + @computed get maxWidth(): number { return this.props.maxWidth ?? Infinity } @@ -602,10 +667,18 @@ export class MarkdownTextWrap extends React.Component { return max(lineLengths) ?? 0 } + @computed get singleLineHeight(): number { + return this.fontSize * this.lineHeight + } + + @computed get lastLineWidth(): number { + return sumBy(last(this.htmlLines), (token) => token.width) ?? 0 + } + @computed get height(): number { - const { htmlLines, lineHeight, fontSize } = this + const { htmlLines } = this if (htmlLines.length === 0) return 0 - return htmlLines.length * lineHeight * fontSize + return htmlLines.length * this.singleLineHeight } @computed get style(): any { @@ -648,13 +721,13 @@ export class MarkdownTextWrap extends React.Component { detailsMarker?: DetailsMarker id?: string } = {} - ): React.ReactElement | null { + ): React.ReactElement { const { fontSize, lineHeight } = this const lines = detailsMarker === "superscript" ? this.svgLinesWithDodReferenceNumbers : this.svgLines - if (lines.length === 0) return null + if (lines.length === 0) return <> // Magic number set through experimentation. // The HTML and SVG renderers need to position lines identically. @@ -1092,3 +1165,13 @@ function appendReferenceNumbers( return appendedTokens } + +function maybeBoldMarkdownText({ + text, + bold, +}: { + text: string + bold?: boolean +}): string { + return bold ? `**${text}**` : text +} diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts index 4207764d3ec..8d21c1d01b9 100755 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts @@ -145,60 +145,3 @@ describe("lines()", () => { ]) }) }) - -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 7912d62accc..34f768790ee 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) @@ -340,4 +319,12 @@ export class TextWrap { ) } + + render( + x: number, + y: number, + options: SVGRenderProps = {} + ): React.ReactElement { + return this.renderSVG(x, y, options) + } } 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 7bb725b4154..00000000000 --- 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 d540132ed70..00000000000 --- 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 cbe2a89077a..ab0603ca199 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/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index dbcc3d0127d..108de0a7e31 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -11,7 +11,7 @@ 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" @@ -53,7 +53,7 @@ export interface LineLabelSeries extends ChartSeries { } interface SizedSeries extends LineLabelSeries { - textWrap: TextWrap | TextWrapGroup + textWrap: TextWrap | MarkdownTextWrap annotationTextWrap?: TextWrap width: number height: number @@ -162,29 +162,25 @@ 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.textWrap.renderSVG( + labelText.x, + labelText.y, + { + textProps: { + ...textProps, + // might override the textWrap's fontWeight + fontWeight: series.fontWeight, + }, + } + )} - ) : ( - - {series.textWrap.render(labelText.x, labelText.y, { - showTextOutline: this.showTextOutline, - textOutlineColor: this.textOutlineColor, - textProps, - })} - ) })} @@ -398,7 +394,7 @@ export class LineLegend extends React.Component { private makeLabelTextWrap( series: LineLabelSeries - ): TextWrap | TextWrapGroup { + ): TextWrap | MarkdownTextWrap { if (!series.formattedValue) { return new TextWrap({ text: series.label, @@ -407,29 +403,28 @@ export class LineLegend extends React.Component { // 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, + // that's why we always use bold labels to compute the layout, // but might render them later using a regular font weight. fontWeight: 700, }) } - // text label fragment - const textLabel = { text: series.label, fontWeight: 700 } + // text label fragments + const textLabel = { text: series.label, bold: true } + const valueLabel = { text: series.formattedValue, bold: false } - // value label fragment const newLine = series.placeFormattedValueInNewLine ? "always" : "avoid-wrap" - const valueLabel = { - text: series.formattedValue, - fontWeight: 400, - newLine, - } - return new TextWrapGroup({ - fragments: [textLabel, valueLabel], - maxWidth: this.textMaxWidth, - fontSize: this.fontSize, + return MarkdownTextWrap.fromFragments({ + main: textLabel, + secondary: valueLabel, + newLine, + textWrapProps: { + maxWidth: this.textMaxWidth, + fontSize: this.fontSize, + }, }) } diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts index 59905fa1081..aca7cf9b017 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,7 @@ export interface LineLabelSeries extends ChartSeries { } export interface SizedSeries extends LineLabelSeries { - textWrap: TextWrap | TextWrapGroup + textWrap: TextWrap | MarkdownTextWrap annotationTextWrap?: TextWrap width: number height: number