diff --git a/.gitignore b/.gitignore index e33cb39db80..0c56cb718ec 100755 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ dist/ .dev.vars **/tsup.config.bundled*.mjs cfstorage/ +vite.*.mjs diff --git a/packages/@ourworldindata/grapher/src/halo/Halo.tsx b/packages/@ourworldindata/components/src/Halo/Halo.tsx similarity index 75% rename from packages/@ourworldindata/grapher/src/halo/Halo.tsx rename to packages/@ourworldindata/components/src/Halo/Halo.tsx index 6986cbb52a6..6732a911d03 100644 --- a/packages/@ourworldindata/grapher/src/halo/Halo.tsx +++ b/packages/@ourworldindata/components/src/Halo/Halo.tsx @@ -13,13 +13,17 @@ const defaultHaloStyle: React.CSSProperties = { export function Halo(props: { id: React.Key children: React.ReactElement - background?: Color + show?: boolean + outlineColor?: Color style?: React.CSSProperties }): React.ReactElement { + const show = props.show ?? true + if (!show) return props.children + const defaultStyle = { ...defaultHaloStyle, - fill: props.background ?? defaultHaloStyle.fill, - stroke: props.background ?? defaultHaloStyle.stroke, + fill: props.outlineColor ?? defaultHaloStyle.fill, + stroke: props.outlineColor ?? defaultHaloStyle.stroke, } const halo = React.cloneElement(props.children, { style: { diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts index 8d21c1d01b9..4207764d3ec 100755 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts @@ -145,3 +145,60 @@ 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 4f0ece86fa4..87ba9eddfec 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx @@ -1,4 +1,4 @@ -import { max, stripHTML, Bounds, FontFamily } from "@ourworldindata/utils" +import { max, stripHTML, Bounds, FontFamily, last } from "@ourworldindata/utils" import { computed } from "mobx" import React from "react" import { Fragment, joinFragments, splitIntoFragments } from "./TextWrapUtils" @@ -11,6 +11,7 @@ interface TextWrapProps { lineHeight?: number fontSize: FontSize fontWeight?: number + firstLineOffset?: number separators?: string[] rawHtml?: boolean } @@ -80,6 +81,9 @@ 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. @@ -148,15 +152,27 @@ export class TextWrap { ? stripHTML(joinFragments(nextLine)) : joinFragments(nextLine) - const nextBounds = Bounds.forText(text, { + let nextBounds = Bounds.forText(text, { fontSize, fontWeight, }) - if ( - startsWithNewline(fragment.text) || - (nextBounds.width + 10 > maxWidth && line.length >= 1) - ) { + // 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) { // Introduce a newline _before_ this word lines.push({ text: joinFragments(line), @@ -194,16 +210,27 @@ export class TextWrap { else return lines } + @computed get lineCount(): number { + return this.lines.length + } + + @computed get singleLineHeight(): number { + return this.fontSize * this.lineHeight + } + @computed get height(): number { - const { lines, lineHeight, fontSize } = this - if (lines.length === 0) return 0 - return lines.length * lineHeight * fontSize + if (this.lineCount === 0) return 0 + return this.lineCount * this.singleLineHeight } @computed get width(): number { return max(this.lines.map((l) => l.width)) ?? 0 } + @computed get lastLineWidth(): number { + return last(this.lines)?.width ?? 0 + } + @computed get htmlStyle(): any { const { fontSize, fontWeight, lineHeight } = this return { @@ -251,10 +278,11 @@ export class TextWrap { // overlap (see storybook of this component). const HEIGHT_CORRECTION_FACTOR = 0.74 - const textHeight = (lines[0].height ?? 0) * HEIGHT_CORRECTION_FACTOR + const textHeight = max(lines.map((line) => line.height)) ?? 0 + const correctedTextHeight = textHeight * HEIGHT_CORRECTION_FACTOR const containerHeight = lineHeight * fontSize const yOffset = - y + (containerHeight - (containerHeight - textHeight) / 2) + y + (containerHeight - (containerHeight - correctedTextHeight) / 2) return [x, yOffset] } @@ -266,10 +294,17 @@ export class TextWrap { textProps, id, }: { textProps?: React.SVGProps; id?: string } = {} - ): React.ReactElement | null { - const { props, lines, fontSize, fontWeight, lineHeight } = this - - if (lines.length === 0) return null + ): React.ReactElement { + const { + props, + lines, + fontSize, + fontWeight, + lineHeight, + firstLineOffset, + } = this + + if (lines.length === 0) return <> const [correctedX, correctedY] = this.getPositionForSvgRendering(x, y) @@ -283,25 +318,21 @@ export class TextWrap { {...textProps} > {lines.map((line, i) => { + const x = correctedX + (i === 0 ? firstLineOffset : 0) + const y = correctedY + lineHeight * fontSize * i + if (props.rawHtml) return ( ) else return ( - + {line.text} ) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts new file mode 100644 index 00000000000..7bb725b4154 --- /dev/null +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts @@ -0,0 +1,140 @@ +#! /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 new file mode 100644 index 00000000000..05a4f16eda4 --- /dev/null +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.tsx @@ -0,0 +1,308 @@ +import 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 c7118e4ab21..3f544ac9044 100644 --- a/packages/@ourworldindata/components/src/index.ts +++ b/packages/@ourworldindata/components/src/index.ts @@ -1,4 +1,5 @@ export { TextWrap, shortenForTargetWidth } from "./TextWrap/TextWrap.js" +export { TextWrapGroup } from "./TextWrap/TextWrapGroup.js" export { MarkdownTextWrap, @@ -59,3 +60,5 @@ export { } from "./SharedDataPageConstants.js" export { Button } from "./Button/Button.js" + +export { Halo } from "./Halo/Halo.js" diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 501d646456d..249920b44f0 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -26,11 +26,11 @@ export const autoDetectYColumnSlugs = (manager: ChartManager): string[] => { } export const getDefaultFailMessage = (manager: ChartManager): string => { - if (manager.table.rootTable.isBlank) return `No table loaded yet.` + if (manager.table.rootTable.isBlank) return `No table loaded yet` if (manager.table.rootTable.entityNameColumn.isMissing) - return `Table is missing an EntityName column.` + return `Table is missing an EntityName column` if (manager.table.rootTable.timeColumn.isMissing) - return `Table is missing a Time column.` + return `Table is missing a Time column` const yColumnSlugs = autoDetectYColumnSlugs(manager) if (!yColumnSlugs.length) return "Missing Y axis column" const selection = makeSelectionArray(manager.selection) diff --git a/packages/@ourworldindata/grapher/src/controls/ChartIcons.tsx b/packages/@ourworldindata/grapher/src/controls/ChartIcons.tsx index 00b89c46017..6ca49c648f9 100644 --- a/packages/@ourworldindata/grapher/src/controls/ChartIcons.tsx +++ b/packages/@ourworldindata/grapher/src/controls/ChartIcons.tsx @@ -93,18 +93,22 @@ export const chartIcons: Record = { [GRAPHER_CHART_TYPES.SlopeChart]: ( - + + + + + ), diff --git a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.scss b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.scss index ca64945c50b..2371e0b0807 100644 --- a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.scss +++ b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.scss @@ -22,7 +22,8 @@ vertical-align: -1.625px; &.scatter, - &.marimekko { + &.marimekko, + &.slope { --size: 14px; } } diff --git a/packages/@ourworldindata/grapher/src/core/grapher.scss b/packages/@ourworldindata/grapher/src/core/grapher.scss index a1042fc0d28..9e988476624 100644 --- a/packages/@ourworldindata/grapher/src/core/grapher.scss +++ b/packages/@ourworldindata/grapher/src/core/grapher.scss @@ -78,6 +78,7 @@ $zindex-controls-drawer: 150; @import "../slideInDrawer/SlideInDrawer.scss"; @import "../sidePanel/SidePanel.scss"; @import "../controls/Dropdown.scss"; + @import "../scatterCharts/NoDataSection.scss"; } // These rules are currently used elsewhere in the site. e.g. Explorers diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index c1159cbc695..d75109dcf9b 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -31,11 +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, - LineLegendManager, -} from "../lineLegend/LineLegend" +import { LineLegend, LineLabelSeries } from "../lineLegend/LineLegend" import { ComparisonLine } from "../scatterCharts/ComparisonLine" import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" import { @@ -56,6 +52,7 @@ import { MissingDataStrategy, ColorScaleConfigInterface, ColorSchemeName, + VerticalAlign, } from "@ourworldindata/types" import { GRAPHER_AXIS_LINE_WIDTH_THICK, @@ -365,7 +362,6 @@ export class LineChart }> implements ChartInterface, - LineLegendManager, AxisManager, ColorScaleManager, HorizontalColorLegendManager @@ -847,7 +843,7 @@ export class LineChart } @computed get lineLegendX(): number { - return this.bounds.right - (this.lineLegendDimensions?.width || 0) + return this.bounds.right - this.lineLegendWidth } @computed get lineLegendY(): [number, number] { @@ -880,10 +876,18 @@ export class LineChart .on("end", () => this.forceUpdate()) // Important in case bounds changes during transition } - @computed private get lineLegendDimensions(): LineLegend | undefined { - return !this.manager.showLegend - ? undefined - : new LineLegend({ manager: this }) + @computed private get lineLegendWidth(): number { + if (!this.manager.showLegend) return 0 + + // only pass props that are required to calculate + // the width to avoid circular dependencies + return LineLegend.stableWidth({ + labelSeries: this.lineLegendSeries, + maxWidth: this.maxLineLegendWidth, + fontSize: this.fontSize, + fontWeight: this.fontWeight, + verticalAlign: VerticalAlign.top, + }) } @computed get availableFacetStrategies(): FacetStrategy[] { @@ -944,7 +948,22 @@ export class LineChart backgroundColor={this.manager.backgroundColor} /> ))} - {manager.showLegend && } + {manager.showLegend && ( + + )} { - const legend = new LineLegend({ manager }) + const legend = new LineLegend(props) expect(legend.sizedLabels.length).toEqual(2) }) diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 01613c1a48c..857b7ce8e8e 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -14,35 +14,46 @@ import { last, maxBy, } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" +import { TextWrap, TextWrapGroup, Halo } from "@ourworldindata/components" import { computed } from "mobx" import { observer } from "mobx-react" import { VerticalAxis } from "../axis/Axis" -import { EntityName } from "@ourworldindata/types" -import { BASE_FONT_SIZE, GRAPHER_FONT_SCALE_12 } from "../core/GrapherConstants" +import { + Color, + EntityName, + SeriesName, + VerticalAlign, +} from "@ourworldindata/types" +import { + BASE_FONT_SIZE, + GRAPHER_BACKGROUND_DEFAULT, + GRAPHER_FONT_SCALE_12, +} from "../core/GrapherConstants" import { ChartSeries } from "../chart/ChartInterface" import { darkenColorForText } from "../color/ColorUtils" +import { AxisConfig } from "../axis/AxisConfig.js" // Minimum vertical space between two legend items -const LEGEND_ITEM_MIN_SPACING = 2 +const LEGEND_ITEM_MIN_SPACING = 4 // Horizontal distance from the end of the chart to the start of the marker const MARKER_MARGIN = 4 // Space between the label and the annotation -const ANNOTATION_PADDING = 2 - -const LEFT_PADDING = 35 +const ANNOTATION_PADDING = 1 +const DEFAULT_CONNECTOR_LINE_WIDTH = 25 const DEFAULT_FONT_WEIGHT = 400 export interface LineLabelSeries extends ChartSeries { label: string yValue: number annotation?: string + formattedValue?: string + placeFormattedValueInNewLine?: boolean yRange?: [number, number] } interface SizedSeries extends LineLabelSeries { - textWrap: TextWrap + textWrap: TextWrapGroup annotationTextWrap?: TextWrap width: number height: number @@ -90,28 +101,51 @@ function stackGroupVertically( class LineLabels extends React.Component<{ series: PlacedSeries[] uniqueKey: string - needsLines: boolean + needsConnectorLines: boolean + showTextOutline?: boolean + textOutlineColor?: Color + anchor?: "start" | "end" isFocus?: boolean isStatic?: boolean onClick?: (series: PlacedSeries) => void onMouseOver?: (series: PlacedSeries) => void onMouseLeave?: (series: PlacedSeries) => void }> { - @computed get markers(): { + @computed private get textOpacity(): number { + return this.props.isFocus ? 1 : 0.6 + } + + @computed private get anchor(): "start" | "end" { + return this.props.anchor ?? "start" + } + + @computed private get showTextOutline(): boolean { + return this.props.showTextOutline ?? false + } + + @computed private get textOutlineColor(): Color { + return this.props.textOutlineColor ?? GRAPHER_BACKGROUND_DEFAULT + } + + @computed private get markers(): { series: PlacedSeries labelText: { x: number; y: number } connectorLine: { x1: number; x2: number } }[] { return this.props.series.map((series) => { + const direction = this.anchor === "start" ? 1 : -1 + const markerMargin = direction * MARKER_MARGIN + const connectorLineWidth = direction * DEFAULT_CONNECTOR_LINE_WIDTH + const { x } = series.origBounds const connectorLine = { - x1: x + MARKER_MARGIN, - x2: x + LEFT_PADDING - MARKER_MARGIN, + x1: x + markerMargin, + x2: x + connectorLineWidth - markerMargin, } - const textX = this.props.needsLines - ? connectorLine.x2 + MARKER_MARGIN - : x + MARKER_MARGIN + const textX = this.props.needsConnectorLines + ? connectorLine.x2 + markerMargin + : x + markerMargin const textY = series.bounds.y return { @@ -122,27 +156,25 @@ class LineLabels extends React.Component<{ }) } - @computed get textOpacity(): number { - return this.props.isFocus ? 1 : 0.6 - } - - @computed get textLabels(): React.ReactElement { + @computed private get textLabels(): React.ReactElement { return ( {this.markers.map(({ series, labelText }, index) => { + const key = getSeriesKey( + series, + index, + this.props.uniqueKey + ) const textColor = darkenColorForText(series.color) return ( - + {series.textWrap.render(labelText.x, labelText.y, { + showTextOutline: this.showTextOutline, + textOutlineColor: this.textOutlineColor, textProps: { fill: textColor, opacity: this.textOpacity, + textAnchor: this.anchor, }, })} @@ -152,7 +184,7 @@ class LineLabels extends React.Component<{ ) } - @computed get textAnnotations(): React.ReactElement | void { + @computed private get textAnnotations(): React.ReactElement | void { const markersWithAnnotations = this.markers.filter( ({ series }) => series.annotationTextWrap !== undefined ) @@ -160,34 +192,42 @@ class LineLabels extends React.Component<{ return ( {markersWithAnnotations.map(({ series, labelText }, index) => { + const key = getSeriesKey( + series, + index, + this.props.uniqueKey + ) + if (!series.annotationTextWrap) return return ( - - {series.annotationTextWrap?.render( + {series.annotationTextWrap.render( labelText.x, - labelText.y + series.textWrap.height, + labelText.y + + series.textWrap.height + + ANNOTATION_PADDING, { textProps: { fill: "#333", opacity: this.textOpacity, + textAnchor: this.anchor, style: { fontWeight: 300 }, }, } )} - + ) })} ) } - @computed get connectorLines(): React.ReactElement | void { - if (!this.props.needsLines) return + @computed private get connectorLines(): React.ReactElement | void { + if (!this.props.needsConnectorLines) return return ( {this.markers.map(({ series, connectorLine }, index) => { @@ -224,10 +264,14 @@ class LineLabels extends React.Component<{ ) } - @computed get interactions(): React.ReactElement | void { + @computed private get interactions(): React.ReactElement | void { return ( {this.props.series.map((series, index) => { + const x = + this.anchor === "start" + ? series.origBounds.x + : series.origBounds.x - series.bounds.width return ( void - onLineLegendClick?: (key: EntityName) => void - onLineLegendMouseLeave?: () => void - focusedSeriesNames: EntityName[] - yAxis: VerticalAxis - lineLegendY?: [number, number] - lineLegendX?: number + showTextOutlines?: boolean + textOutlineColor?: Color + // used to determine which series should be labelled when there is limited space - seriesSortedByImportance?: EntityName[] - isStatic?: boolean + seriesSortedByImportance?: SeriesName[] + + // interactions + isStatic?: boolean // don't add interactions if true + focusedSeriesNames?: SeriesName[] // currently in focus + onClick?: (key: SeriesName) => void + onMouseOver?: (key: SeriesName) => void + onMouseLeave?: () => void } @observer -export class LineLegend extends React.Component<{ - manager: LineLegendManager -}> { +export class LineLegend extends React.Component { + /** + * Larger than the actual width since the width of the connector lines + * is always added, even if they're not rendered. + * + * This is partly due to a circular dependency (in line and stacked area + * charts), partly to avoid jumpy layout changes (slope charts). + */ + static stableWidth(props: LineLegendProps): number { + const test = new LineLegend(props) + return test.stableWidth + } + + static fontSize(props: Partial): number { + const test = new LineLegend(props as LineLegendProps) + return test.fontSize + } + + static maxLevel(props: Partial): number { + const test = new LineLegend(props as LineLegendProps) + return test.maxLevel + } + + static visibleSeriesNames(props: LineLegendProps): SeriesName[] { + const test = new LineLegend(props as LineLegendProps) + return test.visibleSeriesNames + } + @computed private get fontSize(): number { - return GRAPHER_FONT_SCALE_12 * (this.manager.fontSize ?? BASE_FONT_SIZE) + return GRAPHER_FONT_SCALE_12 * (this.props.fontSize ?? BASE_FONT_SIZE) } @computed private get fontWeight(): number { - return this.manager.fontWeight ?? DEFAULT_FONT_WEIGHT + return this.props.fontWeight ?? DEFAULT_FONT_WEIGHT } @computed private get maxWidth(): number { - return this.manager.maxLineLegendWidth ?? 300 + return this.props.maxWidth ?? 300 + } + + @computed private get yAxis(): VerticalAxis { + return this.props.yAxis ?? new VerticalAxis(new AxisConfig()) + } + + @computed private get verticalAlign(): VerticalAlign { + return this.props.verticalAlign ?? VerticalAlign.middle } @computed.struct get sizedLabels(): SizedSeries[] { - const { fontSize, fontWeight, maxWidth } = this - const maxTextWidth = maxWidth - LEFT_PADDING + const { fontSize, maxWidth } = this + const maxTextWidth = maxWidth - DEFAULT_CONNECTOR_LINE_WIDTH const maxAnnotationWidth = Math.min(maxTextWidth, 150) - return this.manager.labelSeries.map((label) => { + return this.props.labelSeries.map((label) => { + // if a formatted value is given, make the main label bold + const fontWeight = label.formattedValue ? 700 : this.fontWeight + + const mainLabel = { text: label.label, fontWeight } + const valueLabel = label.formattedValue + ? { + text: label.formattedValue, + newLine: (label.placeFormattedValueInNewLine + ? "always" + : "avoid-wrap") as "always" | "avoid-wrap", + } + : undefined + const labelFragments = excludeUndefined([mainLabel, valueLabel]) + const textWrap = new TextWrapGroup({ + fragments: labelFragments, + maxWidth: maxTextWidth, + fontSize, + }) const annotationTextWrap = label.annotation ? new TextWrap({ text: label.annotation, @@ -316,86 +422,100 @@ export class LineLegend extends React.Component<{ lineHeight: 1, }) : undefined - const textWrap = new TextWrap({ - text: label.label, - maxWidth: maxTextWidth, - fontSize, - fontWeight, - lineHeight: 1, - }) + + const annotationWidth = annotationTextWrap + ? annotationTextWrap.width + : 0 + const annotationHeight = annotationTextWrap + ? ANNOTATION_PADDING + annotationTextWrap.height + : 0 + return { ...label, textWrap, annotationTextWrap, - width: - LEFT_PADDING + - Math.max( - textWrap.width, - annotationTextWrap ? annotationTextWrap.width : 0 - ), - height: - textWrap.height + - (annotationTextWrap - ? ANNOTATION_PADDING + annotationTextWrap.height - : 0), + width: Math.max(textWrap.width, annotationWidth), + height: textWrap.height + annotationHeight, } }) } - @computed get width(): number { - if (this.sizedLabels.length === 0) return 0 - return max(this.sizedLabels.map((d) => d.width)) ?? 0 + @computed private get maxLabelWidth(): number { + const { sizedLabels = [] } = this + return max(sizedLabels.map((d) => d.width)) ?? 0 + } + + @computed get stableWidth(): number { + return this.maxLabelWidth + DEFAULT_CONNECTOR_LINE_WIDTH + MARKER_MARGIN } @computed get onMouseOver(): any { - return this.manager.onLineLegendMouseOver ?? noop + return this.props.onMouseOver ?? noop } @computed get onMouseLeave(): any { - return this.manager.onLineLegendMouseLeave ?? noop + return this.props.onMouseLeave ?? noop } @computed get onClick(): any { - return this.manager.onLineLegendClick ?? noop + return this.props.onClick ?? noop + } + + @computed get focusedSeriesNames(): EntityName[] { + return this.props.focusedSeriesNames ?? [] } @computed get isFocusMode(): boolean { return this.sizedLabels.some((label) => - this.manager.focusedSeriesNames.includes(label.seriesName) + this.focusedSeriesNames.includes(label.seriesName) ) } @computed get legendX(): number { - return this.manager.lineLegendX ?? 0 + return this.props.x ?? 0 } @computed get legendY(): [number, number] { - const range = this.manager.lineLegendY ?? this.manager.yAxis.range + const range = this.props.yRange ?? this.yAxis.range return [Math.min(range[1], range[0]), Math.max(range[1], range[0])] } + private getYPositionForSeriesLabel(series: SizedSeries): number { + const y = this.yAxis.place(series.yValue) + const lineHeight = series.textWrap.singleLineHeight + switch (this.verticalAlign) { + case VerticalAlign.middle: + return y - series.height / 2 + case VerticalAlign.top: + return y - lineHeight / 2 + case VerticalAlign.bottom: + return y - series.height + lineHeight / 2 + } + } + // Naive initial placement of each mark at the target height, before collision detection @computed private get initialSeries(): PlacedSeries[] { - const { yAxis } = this.manager - const { legendX, legendY } = this + const { yAxis, legendX, legendY } = this const [legendYMin, legendYMax] = legendY return this.sizedLabels.map((label) => { - // place vertically centered at Y value + const labelHeight = label.height + const labelWidth = label.width + DEFAULT_CONNECTOR_LINE_WIDTH + const midY = yAxis.place(label.yValue) - const initialY = midY - label.height / 2 const origBounds = new Bounds( legendX, - initialY, - label.width, - label.height + midY - label.height / 2, + labelWidth, + labelHeight ) // ensure label doesn't go beyond the top or bottom of the chart + const initialY = this.getYPositionForSeriesLabel(label) const y = Math.min( Math.max(initialY, legendYMin), - legendYMax - label.height + legendYMax - labelHeight ) - const bounds = new Bounds(legendX, y, label.width, label.height) + const bounds = new Bounds(legendX, y, labelWidth, labelHeight) return { ...label, @@ -490,9 +610,9 @@ export class LineLegend extends React.Component<{ } @computed get sortedSeriesByImportance(): PlacedSeries[] | undefined { - if (!this.manager.seriesSortedByImportance) return undefined + if (!this.props.seriesSortedByImportance) return undefined return excludeUndefined( - this.manager.seriesSortedByImportance.map((seriesName) => + this.props.seriesSortedByImportance.map((seriesName) => this.initialSeriesByName.get(seriesName) ) ) @@ -639,8 +759,12 @@ export class LineLegend extends React.Component<{ } } + @computed get visibleSeriesNames(): SeriesName[] { + return this.partialInitialSeries.map((series) => series.seriesName) + } + @computed private get backgroundSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this.manager + const { focusedSeriesNames } = this const { isFocusMode } = this return this.placedSeries.filter( (mark) => @@ -649,7 +773,7 @@ export class LineLegend extends React.Component<{ } @computed private get focusedSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this.manager + const { focusedSeriesNames } = this const { isFocusMode } = this return this.placedSeries.filter( (mark) => @@ -662,14 +786,21 @@ export class LineLegend extends React.Component<{ return this.placedSeries.some((series) => series.totalLevels > 1) } + @computed private get maxLevel(): number { + return max(this.placedSeries.map((series) => series.totalLevels)) ?? 0 + } + private renderBackground(): React.ReactElement { return ( this.onMouseOver(series.seriesName) } @@ -684,9 +815,12 @@ export class LineLegend extends React.Component<{ this.onMouseOver(series.seriesName) } @@ -698,10 +832,6 @@ export class LineLegend extends React.Component<{ ) } - @computed get manager(): LineLegendManager { - return this.props.manager - } - render(): React.ReactElement { return ( -
+
No data
-
    - {displayedNames.map((entityName) => ( -
  • - {entityName} -
  • - ))} -
- {remaining > 0 && ( -
& {remaining === 1 ? "one" : remaining} more
- )} +
+
    + {displayedNames.map((entityName) => ( +
  • {entityName}
  • + ))} +
+ {remaining > 0 && ( +
& {remaining === 1 ? "one" : remaining} more
+ )} +
) } diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPointsWithLabels.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPointsWithLabels.tsx index 53eb8859604..fd8d306752b 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPointsWithLabels.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPointsWithLabels.tsx @@ -18,7 +18,7 @@ import { import { computed, action, observable } from "mobx" import { observer } from "mobx-react" import React from "react" -import { Halo } from "../halo/Halo" +import { Halo } from "@ourworldindata/components" import { MultiColorPolyline } from "./MultiColorPolyline" import { ScatterPointsWithLabelsProps, @@ -425,7 +425,7 @@ export class ScatterPointsWithLabels extends React.Component @@ -96,8 +104,10 @@ export class SlopeChart }> implements ChartInterface { - slopeAreaRef: React.RefObject = React.createRef() - defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines + private slopeAreaRef: React.RefObject = React.createRef() + private defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines + + private sidebarMargin = 10 @observable hoveredSeriesName?: string @observable tooltipState = new TooltipState<{ @@ -183,7 +193,11 @@ export class SlopeChart return this.props.bounds ?? DEFAULT_BOUNDS } - @computed get fontSize() { + @computed private get innerBounds(): Bounds { + return this.bounds.padRight(this.sidebarWidth + this.sidebarMargin) + } + + @computed get fontSize(): number { return this.manager.fontSize ?? BASE_FONT_SIZE } @@ -195,17 +209,16 @@ export class SlopeChart return this.manager.missingDataStrategy || MissingDataStrategy.auto } - @computed private get selectionArray() { + @computed private get selectionArray(): SelectionArray { return makeSelectionArray(this.manager.selection) } - @computed private get formatColumn() { + @computed private get formatColumn(): CoreColumn { return this.yColumns[0] } @computed private get lineStrokeWidth(): number { - const factor = this.manager.isStaticAndSmall ? 2 : 1 - return factor * 2 + return this.manager.isStaticAndSmall ? 3 : 1.5 } @computed private get backgroundColor(): string { @@ -276,9 +289,11 @@ export class SlopeChart const { canSelectMultipleEntities = false } = this.manager const { availableEntityNames } = this.transformedTable + const displayEntityName = + getShortNameForEntity(entityName) ?? entityName const columnName = column.nonEmptyDisplayName const seriesName = getSeriesName({ - entityName, + entityName: displayEntityName, columnName, seriesStrategy, availableEntityNames, @@ -419,6 +434,9 @@ export class SlopeChart // nothing to show if there are no series with missing data if (this.noDataSeries.length === 0) return false + // the No Data section is HTML and won't show up in the SVG export + if (this.manager.isStatic) return false + // we usually don't show the no data section if columns are plotted // (since columns don't appear in the entity selector there is no need // to explain that a column is missing – it just adds noise). but if @@ -475,7 +493,7 @@ export class SlopeChart } @computed private get yAxisWidth(): number { - return this.yAxis.width + 5 // 5px account for the tick marks + return this.yAxis.width } @computed private get xScale(): ScaleLinear { @@ -493,43 +511,159 @@ export class SlopeChart : 0 } - @computed private get maxLabelWidth(): number { - // TODO: copied from line legend - const fontSize = - GRAPHER_FONT_SCALE_12 * (this.manager.fontSize ?? BASE_FONT_SIZE) - return ( - max( - this.series.map( - (series) => - Bounds.forText(series.seriesName, { fontSize }).width - ) - ) ?? 0 + @computed get maxLineLegendWidth(): number { + return 0.25 * this.innerBounds.width + } + + @computed get lineLegendFontSize(): number { + return LineLegend.fontSize({ fontSize: this.fontSize }) + } + + @computed get lineLegendYRange(): [number, number] { + const top = this.bounds.top + + const bottom = + this.bounds.bottom - + // leave space for the x-axis labels + BOTTOM_PADDING + + // but allow for a little extra space + this.lineLegendFontSize / 2 + + return [top, bottom] + } + + @computed private get lineLegendPropsCommon(): Partial { + return { + yAxis: this.yAxis, + maxWidth: this.maxLineLegendWidth, + fontSize: this.fontSize, + isStatic: this.manager.isStatic, + yRange: this.lineLegendYRange, + verticalAlign: VerticalAlign.top, + showTextOutlines: true, + textOutlineColor: this.backgroundColor, + focusedSeriesNames: this.focusedSeriesNames, + onMouseOver: this.onLineLegendMouseOver, + onMouseLeave: this.onLineLegendMouseLeave, + } + } + + @computed private get lineLegendPropsRight(): Partial { + return { xAnchor: "start" } + } + + @computed private get lineLegendPropsLeft(): Partial { + return { + xAnchor: "end", + seriesSortedByImportance: + this.seriesSortedByImportanceForLineLegendLeft, + } + } + + private formatValue(value: number): string { + return this.formatColumn.formatValueShortWithAbbreviations(value) + } + + @computed get lineLegendMaxLevelLeft(): number { + if (!this.manager.showLegend) return 0 + + // can't use `lineLegendSeriesLeft` due to a circular dependency + const series = this.series.map((series) => + this.constructSingleLineLegendSeries( + series, + (series) => series.start.value, + { showSeriesName: false } + ) ) + + return LineLegend.maxLevel({ + labelSeries: series, + ...this.lineLegendPropsCommon, + seriesSortedByImportance: + this.seriesSortedByImportanceForLineLegendLeft, + // not including `lineLegendPropsLeft` due to a circular dependency + }) } - @computed get maxLineLegendWidth(): number { - // todo: copied from line legend (left padding, marker margin) - return Math.min(this.maxLabelWidth + 35 + 4, this.bounds.width / 3) + @computed get lineLegendWidthLeft(): number { + if (!this.manager.showLegend) return 0 + return LineLegend.stableWidth({ + labelSeries: this.lineLegendSeriesLeft, + ...this.lineLegendPropsCommon, + ...this.lineLegendPropsLeft, + }) + } + + @computed get lineLegendRight(): LineLegend | undefined { + if (!this.manager.showLegend) return undefined + return new LineLegend({ + labelSeries: this.lineLegendSeriesRight, + ...this.lineLegendPropsCommon, + ...this.lineLegendPropsRight, + }) + } + + @computed get lineLegendWidthRight(): number { + return this.lineLegendRight?.stableWidth ?? 0 + } + + @computed get visibleLineLegendLabelsRight(): Set { + return new Set(this.lineLegendRight?.visibleSeriesNames ?? []) + } + + @computed get seriesSortedByImportanceForLineLegendLeft(): SeriesName[] { + return this.series + .map((s) => s.seriesName) + .sort((s1: SeriesName, s2: SeriesName): number => { + const PREFER_S1 = -1 + const PREFER_S2 = 1 + + const s1_isLabelled = this.visibleLineLegendLabelsRight.has(s1) + const s2_isLabelled = this.visibleLineLegendLabelsRight.has(s2) + + // prefer to show value labels for series that are already labelled + if (s1_isLabelled && !s2_isLabelled) return PREFER_S1 + if (s2_isLabelled && !s1_isLabelled) return PREFER_S2 + + return 0 + }) } @computed get xRange(): [number, number] { - const lineLegendWidth = this.maxLineLegendWidth + LINE_LEGEND_PADDING + const lineLegendWidthLeft = + this.lineLegendWidthLeft + LINE_LEGEND_PADDING + const lineLegendWidthRight = + this.lineLegendWidthRight + LINE_LEGEND_PADDING + const chartAreaWidth = this.innerBounds.width + + // start and end value when the slopes are as wide as possible + const minStartX = + this.innerBounds.x + this.yAxisWidth + lineLegendWidthLeft + const maxEndX = this.innerBounds.right - lineLegendWidthRight + + // use all available space if the chart is narrow + if (this.manager.isNarrow) { + return [minStartX, maxEndX] + } + + const offset = 0.25 + let startX = this.innerBounds.x + offset * chartAreaWidth + let endX = this.innerBounds.right - offset * chartAreaWidth + + // make sure the start and end values are within the bounds + startX = Math.max(startX, minStartX) + endX = Math.min(endX, maxEndX) // pick a reasonable max width based on an ideal aspect ratio - const idealAspectRatio = 0.6 - const chartAreaWidth = this.bounds.width - this.sidebarWidth + const idealAspectRatio = 0.9 const availableWidth = - chartAreaWidth - this.yAxisWidth - lineLegendWidth + chartAreaWidth - + this.yAxisWidth - + lineLegendWidthLeft - + lineLegendWidthRight const idealWidth = idealAspectRatio * this.bounds.height const maxSlopeWidth = Math.min(idealWidth, availableWidth) - let startX = - this.bounds.x + Math.max(0.25 * chartAreaWidth, this.yAxisWidth + 4) - let endX = - this.bounds.x + - chartAreaWidth - - Math.max(0.25 * chartAreaWidth, lineLegendWidth) - const currentSlopeWidth = endX - startX if (currentSlopeWidth > maxSlopeWidth) { const padding = currentSlopeWidth - maxSlopeWidth @@ -540,27 +674,58 @@ export class SlopeChart return [startX, endX] } - @computed get lineLegendX(): number { - return this.xRange[1] + LINE_LEGEND_PADDING + @computed get useCompactLayout(): boolean { + return !!this.manager.isSemiNarrow } - // used by LineLegend @computed get focusedSeriesNames(): SeriesName[] { return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] } - // used in LineLegend - @computed get labelSeries(): LineLabelSeries[] { - return this.series.map((series) => { - const { seriesName, color, end, annotation } = series - return { - color, - seriesName, - label: seriesName, - annotation, - yValue: end.value, - } - }) + private constructSingleLineLegendSeries( + series: SlopeChartSeries, + getValue: (series: SlopeChartSeries) => number, + { + showSeriesName, + showAnnotation, + }: { + showSeriesName?: boolean + showAnnotation?: boolean + } + ): LineLabelSeries { + const { seriesName, color, annotation } = series + const value = getValue(series) + const formattedValue = this.formatValue(value) + return { + color, + seriesName, + annotation: showAnnotation ? annotation : undefined, + label: showSeriesName ? seriesName : formattedValue, + formattedValue: showSeriesName ? formattedValue : undefined, + placeFormattedValueInNewLine: this.useCompactLayout, + yValue: value, + } + } + + @computed get lineLegendSeriesLeft(): LineLabelSeries[] { + const { showSeriesNamesInLineLegendLeft: showSeriesName } = this + return this.series.map((series) => + this.constructSingleLineLegendSeries( + series, + (series) => series.start.value, + { showSeriesName } + ) + ) + } + + @computed get lineLegendSeriesRight(): LineLabelSeries[] { + return this.series.map((series) => + this.constructSingleLineLegendSeries( + series, + (series) => series.end.value, + { showSeriesName: true, showAnnotation: !this.useCompactLayout } + ) + ) } private playIntroAnimation() { @@ -570,6 +735,7 @@ export class SlopeChart .attr("stroke-dasharray", "100%") .attr("stroke-dashoffset", "100%") .transition() + .duration(600) .attr("stroke-dashoffset", "0%") } @@ -581,12 +747,16 @@ export class SlopeChart } } - private updateTooltipPosition(event: SVGMouseOrTouchEvent) { + @computed private get showSeriesNamesInLineLegendLeft(): boolean { + return this.lineLegendMaxLevelLeft >= 4 + } + + private updateTooltipPosition(event: SVGMouseOrTouchEvent): void { const ref = this.manager.base?.current if (ref) this.tooltipState.position = getRelativeMouse(ref, event) } - private detectHoveredSlope(event: SVGMouseOrTouchEvent) { + private detectHoveredSlope(event: SVGMouseOrTouchEvent): void { const ref = this.slopeAreaRef.current if (!ref) return @@ -635,42 +805,41 @@ export class SlopeChart }, 200) } - @action.bound onSlopeMouseOver(series: SlopeChartSeries) { + @action.bound onSlopeMouseOver(series: SlopeChartSeries): void { this.hoveredSeriesName = series.seriesName this.tooltipState.target = { series } } - @action.bound onSlopeMouseLeave() { + @action.bound onSlopeMouseLeave(): void { this.hoveredSeriesName = undefined this.tooltipState.target = null } mouseFrame?: number - @action.bound onMouseMove(event: SVGMouseOrTouchEvent) { + @action.bound onMouseMove(event: SVGMouseOrTouchEvent): void { this.updateTooltipPosition(event) this.detectHoveredSlope(event) } - @action.bound onMouseLeave() { + @action.bound onMouseLeave(): void { if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) this.onSlopeMouseLeave() } + private failMessageForSingleTimeSelection = + "Two time points needed for comparison" @computed get failMessage(): string { const message = getDefaultFailMessage(this.manager) if (message) return message else if (this.startTime === this.endTime) - return "No data to display for the selected time period" + return this.failMessageForSingleTimeSelection return "" } @computed get helpMessage(): string | undefined { - if ( - this.failMessage === - "No data to display for the selected time period" - ) - return "Try dragging the time slider to display data." + if (this.failMessage === this.failMessageForSingleTimeSelection) + return "Click or drag the timeline to select two different points in time." return undefined } @@ -692,6 +861,9 @@ export class SlopeChart const formatTime = (time: Time) => formatColumn.formatTime(time) + const title = series.seriesName + const titleAnnotation = series.annotation + const actualStartTime = series.start.originalTime const actualEndTime = series.end.originalTime const timeRange = `${formatTime(actualStartTime)} to ${formatTime(actualEndTime)}` @@ -745,7 +917,8 @@ export class SlopeChart offsetX={20} offsetY={-16} style={{ maxWidth: "250px" }} - title={series.seriesName} + title={title} + titleAnnotation={titleAnnotation} subtitle={timeLabel} subtitleFormat={targetYear ? "notice" : undefined} dissolve={fading} @@ -790,9 +963,11 @@ export class SlopeChart return seriesName } - private renderNoDataSection(): React.ReactElement { + private renderNoDataSection(): React.ReactElement | void { + if (!this.showNoDataSection) return + const bounds = new Bounds( - this.bounds.right - this.sidebarWidth, + this.innerBounds.right + this.sidebarMargin, this.bounds.top, this.sidebarWidth, this.bounds.height @@ -821,7 +996,7 @@ export class SlopeChart color={series.color} mode={mode} strokeWidth={this.lineStrokeWidth} - outlineWidth={0.25} + outlineWidth={0.5} outlineStroke={this.backgroundColor} /> ) @@ -829,7 +1004,11 @@ export class SlopeChart private renderSlopes() { if (!this.isFocusModeActive) { - return this.placedSeries.map((series) => this.renderSlope(series)) + return this.placedSeries.map((series) => ( + + {this.renderSlope(series)} + + )) } const [focusedSeries, backgroundSeries] = partition( @@ -839,33 +1018,31 @@ export class SlopeChart return ( <> - {backgroundSeries.map((series) => - this.renderSlope(series, RenderMode.mute) - )} - {focusedSeries.map((series) => - this.renderSlope(series, RenderMode.focus) - )} + {backgroundSeries.map((series) => ( + + {this.renderSlope(series, RenderMode.mute)} + + ))} + {focusedSeries.map((series) => ( + + {this.renderSlope(series, RenderMode.focus)} + + ))} ) } - private renderChartArea() { + private renderChartArea(): React.ReactElement { const { bounds, xDomain, yRange, startX, endX } = this const [bottom, top] = yRange return ( - + + ) + } + + private renderLineLegendLeft(): React.ReactElement { + const uniqYValues = uniq( + this.lineLegendSeriesLeft.map((series) => series.yValue) + ) + const allSlopesStartFromZero = + uniqYValues.length === 1 && uniqYValues[0] === 0 + + // if all values have a start value of 0, show the 0-label only once + if ( + // in relative mode, all slopes start from 0% + this.manager.isRelativeMode || + allSlopesStartFromZero + ) + return ( + + + {this.formatValue(0)} + + + ) + + return ( + + ) + } + + private renderLineLegends(): React.ReactElement | void { + if (!this.manager.showLegend) return + + return ( + <> + {this.renderLineLegendLeft()} + {this.renderLineLegendRight()} + + ) + } + render() { if (this.failMessage) return ( @@ -917,8 +1157,8 @@ export class SlopeChart return ( {this.renderChartArea()} - {this.manager.showLegend && } - {this.showNoDataSection && this.renderNoDataSection()} + {this.renderLineLegends()} + {this.renderNoDataSection()} {this.tooltip} ) @@ -941,7 +1181,7 @@ function Slope({ series, color, mode = RenderMode.default, - dotRadius = 3.5, + dotRadius = 2.5, strokeWidth = 2, outlineWidth = 0.5, outlineStroke = "#fff", @@ -950,6 +1190,8 @@ function Slope({ }: SlopeProps) { const { seriesName, startPoint, endPoint } = series + const showOutline = mode === RenderMode.default || mode === RenderMode.focus + const opacity = { [RenderMode.default]: 1, [RenderMode.focus]: 1, @@ -964,83 +1206,72 @@ function Slope({ onMouseOver={() => onMouseOver?.(series)} onMouseLeave={() => onMouseLeave?.()} > - + )} + - - ) } -interface HaloLineProps extends SVGProps { +/** + * Line with two dots at the ends, drawn as a single path element. + */ +function LineWithDots({ + startPoint, + endPoint, + radius, + color, + lineWidth = 2, + opacity = 1, +}: { startPoint: PointVector endPoint: PointVector - strokeWidth?: number - outlineWidth?: number - outlineStroke?: string -} + radius: number + color: string + lineWidth?: number + opacity?: number +}): React.ReactElement { + const startDotPath = makeCirclePath(startPoint.x, startPoint.y, radius) + const endDotPath = makeCirclePath(endPoint.x, endPoint.y, radius) + + const linePath = makeLinePath( + startPoint.x, + startPoint.y, + endPoint.x, + endPoint.y + ) -function HaloLine(props: HaloLineProps): React.ReactElement { - const { - startPoint, - endPoint, - outlineWidth = 0.5, - outlineStroke = "#fff", - ...styleProps - } = props return ( - <> - - - + ) } interface GridLinesProps { bounds: Bounds yAxis: VerticalAxis - startX: number - endX: number } -function GridLines({ bounds, yAxis, startX, endX }: GridLinesProps) { +function GridLines({ bounds, yAxis }: GridLinesProps) { return ( {yAxis.tickLabels.map((tick) => { @@ -1053,20 +1284,10 @@ function GridLines({ bounds, yAxis, startX, endX }: GridLinesProps) { )} key={tick.formattedValue} > - {/* grid lines connecting the chart area to the axis */} - - {/* grid lines within the chart area */} {label} ) } + +const makeCirclePath = (centerX: number, centerY: number, radius: number) => { + const topX = centerX + const topY = centerY - radius + return `M ${topX},${topY} A ${radius},${radius} 0 1,1 ${topX - 0.0001},${topY}` +} + +const makeLinePath = (x1: number, y1: number, x2: number, y2: number) => { + return `M ${x1},${y1} L ${x2},${y2}` +} diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 58eb7020266..b06229817ce 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -29,11 +29,7 @@ import { import { observer } from "mobx-react" import { DualAxisComponent } from "../axis/AxisViews" import { DualAxis } from "../axis/Axis" -import { - LineLabelSeries, - LineLegend, - LineLegendManager, -} from "../lineLegend/LineLegend" +import { LineLabelSeries, LineLegend } from "../lineLegend/LineLegend" import { NoDataModal } from "../noDataModal/NoDataModal" import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" import { @@ -263,10 +259,7 @@ class Areas extends React.Component { } @observer -export class StackedAreaChart - extends AbstractStackedChart - implements LineLegendManager -{ +export class StackedAreaChart extends AbstractStackedChart { constructor(props: AbstractStackedChartProps) { super(props) } @@ -301,7 +294,7 @@ export class StackedAreaChart }) } - @computed get labelSeries(): LineLabelSeries[] { + @computed get lineLegendSeries(): LineLabelSeries[] { const { midpoints } = this return this.series .map((series, index) => ({ @@ -319,9 +312,16 @@ export class StackedAreaChart return Math.min(150, this.bounds.width / 3) } - @computed get legendDimensions(): LineLegend | undefined { - if (!this.manager.showLegend) return undefined - return new LineLegend({ manager: this }) + @computed get lineLegendWidth(): number { + if (!this.manager.showLegend) return 0 + + // only pass props that are required to calculate + // the width to avoid circular dependencies + return LineLegend.stableWidth({ + labelSeries: this.lineLegendSeries, + maxWidth: this.maxLineLegendWidth, + fontSize: this.fontSize, + }) } @observable tooltipState = new TooltipState<{ @@ -348,8 +348,7 @@ export class StackedAreaChart @observable private hoverTimer?: NodeJS.Timeout @computed protected get paddingForLegendRight(): number { - const { legendDimensions } = this - return legendDimensions ? legendDimensions.width : 0 + return this.lineLegendWidth } @computed get seriesSortedByImportance(): string[] { @@ -656,7 +655,21 @@ export class StackedAreaChart renderLegend(): React.ReactElement | void { if (!this.manager.showLegend) return - return + return ( + + ) } renderStatic(): React.ReactElement { @@ -735,8 +748,8 @@ export class StackedAreaChart } @computed get lineLegendX(): number { - return this.legendDimensions - ? this.bounds.right - this.legendDimensions.width + return this.manager.showLegend + ? this.bounds.right - this.lineLegendWidth : 0 } diff --git a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss index c8e74e151f4..13c0245cee4 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss +++ b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss @@ -67,6 +67,14 @@ font-size: 14px; font-weight: $bold; letter-spacing: 0; + margin-right: 4px; + + .annotation { + display: inline-block; + font-weight: normal; + color: $grey; + font-size: 0.9em; + } } .subtitle { margin: 4px 0 2px 0; diff --git a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx index 5e4523480a9..01d11012823 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx @@ -103,6 +103,7 @@ class TooltipCard extends React.Component< let { id, title, + titleAnnotation, subtitle, subtitleFormat, footer, @@ -189,7 +190,14 @@ class TooltipCard extends React.Component< > {hasHeader && (
- {title &&
{title}
} + {title && ( +
+ {title}{" "} + + {titleAnnotation} + +
+ )} {subtitle && (
{timeNotice && TOOLTIP_ICON.notice} diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts index e3222a8949b..9fae113da6b 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts @@ -28,6 +28,7 @@ export interface TooltipProps { offsetXDirection?: "left" | "right" offsetYDirection?: "upward" | "downward" title?: string | number // header text + titleAnnotation?: string // rendered next to the title, but muted subtitle?: string | number // header deck subtitleFormat?: "notice" | "unit" // optional postprocessing for subtitle footer?: { icon: TooltipFooterIcon; text: string }[]