Skip to content

Commit

Permalink
✨ (slope) redesign / TAS-722 (#4222)
Browse files Browse the repository at this point in the history
### Summary

- Entity names are usually only shown only on the right side
- If it would be otherwise difficult to match a label on the left to the corresponding slope, then the entity name is repeated
    - The heuristic here uses the number of levels used by the line legend, the actual threshold (4) is arbitrary of course
- We prefer to add labels on the left if they're also labelled on the right (this is only important if labels are dropped due to space constraints)
- Entity annotations are hidden on smaller screens
- Entity annotations are also shown in the tooltip (line charts show entity annotations in the tooltip as well) 

### Future improvements

- Show hidden line legend labels on hover
- Toggling the No Data section is quite disruptive. Once we make changes to the entity selector that account for the No data problem, we should remove the section

### Testing

The Multiembedder problem is hot-fixed on [this staging site](http://staging-site-slope-charts) for testing.
  • Loading branch information
sophiamersmann authored Dec 11, 2024
1 parent 93cd99a commit e87de50
Show file tree
Hide file tree
Showing 25 changed files with 1,342 additions and 379 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ dist/
.dev.vars
**/tsup.config.bundled*.mjs
cfstorage/
vite.*.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
57 changes: 57 additions & 0 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
])
})
})
81 changes: 56 additions & 25 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -11,6 +11,7 @@ interface TextWrapProps {
lineHeight?: number
fontSize: FontSize
fontWeight?: number
firstLineOffset?: number
separators?: string[]
rawHtml?: boolean
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
}
Expand All @@ -266,10 +294,17 @@ export class TextWrap {
textProps,
id,
}: { textProps?: React.SVGProps<SVGTextElement>; 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)

Expand All @@ -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 (
<tspan
key={i}
x={correctedX}
y={correctedY + lineHeight * fontSize * i}
x={x}
y={y}
dangerouslySetInnerHTML={{ __html: line.text }}
/>
)
else
return (
<tspan
key={i}
x={correctedX}
y={
correctedY +
(i === 0 ? 0 : lineHeight * fontSize * i)
}
>
<tspan key={i} x={x} y={y}>
{line.text}
</tspan>
)
Expand Down
140 changes: 140 additions & 0 deletions packages/@ourworldindata/components/src/TextWrap/TextWrapGroup.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
Loading

0 comments on commit e87de50

Please sign in to comment.