diff --git a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.stories.tsx b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.stories.tsx index de160e284e..a5de2eac41 100644 --- a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.stories.tsx +++ b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.stories.tsx @@ -179,7 +179,7 @@ const RenderCurrentAndLegacy = ({ }} /> - {legacy.render(0, 0)} + {legacy.renderSVG(0, 0)} {current.renderSVG(0, 0)} diff --git a/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts b/packages/@ourworldindata/components/src/MarkdownTextWrap/MarkdownTextWrap.test.ts index 05da1aaffe..7a0efc9a1f 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 ff1bfdf533..076208059c 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.stories.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.stories.tsx index 33ec426984..f7f4973f6a 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.stories.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.stories.tsx @@ -40,7 +40,7 @@ const HTMLAndSVG = ({ > - {textwrap.render(0, 0)} + {textwrap.renderSVG(0, 0)}
{ ]) }) }) - -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, },