Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ (slope) redesign / TAS-722 #4222

Merged
merged 46 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
de45372
🔨 (line legend) add ability to render right-aligned legend
sophiamersmann Nov 26, 2024
36bed5c
🔨 (line legend) remove manager pattern
sophiamersmann Nov 26, 2024
8875baa
✨ (slope) add second legend on the left
sophiamersmann Nov 26, 2024
3d5bfb2
✨ (slope) show value in the line legend
sophiamersmann Nov 26, 2024
4a7cce4
✨ (slope) show value label inline
sophiamersmann Nov 26, 2024
c08536e
✨ (slope) only show 0% label once in relative mode
sophiamersmann Nov 29, 2024
bc75240
✨ (slope) increase font size, use consistent grid line color
sophiamersmann Nov 29, 2024
570d937
✨ (slope) add entity annotation to tooltip
sophiamersmann Nov 29, 2024
3c31ce2
✨ (slope) render halo for entity labels
sophiamersmann Nov 29, 2024
1bacbc2
✨ (slope) render values next to entity labels
sophiamersmann Dec 2, 2024
a08d6cf
✨ (slope) give labels a bit more space
sophiamersmann Dec 2, 2024
e98bd59
✨ (slope) vertically align label and dot on top
sophiamersmann Dec 2, 2024
02a732b
✨ incrase line height of labels
sophiamersmann Dec 2, 2024
0eacf2e
✨ (slope) consistent spacing between label and value
sophiamersmann Dec 2, 2024
acdc110
✨ (slope) prevent x-label and entity labels from overlapping
sophiamersmann Dec 2, 2024
6e929ea
✨ (slope) only show zero label once if all slopes start from zero
sophiamersmann Dec 2, 2024
32aa934
✨ (slope) show entity label on the left if connector lines are needed
sophiamersmann Dec 2, 2024
73db425
✨ (slope) prevent x-label and line labels to overlap
sophiamersmann Dec 2, 2024
6cd298e
✨ (slope) always show annotation in the tooltip
sophiamersmann Dec 2, 2024
35f13d7
✨ (slope) hide tick marks
sophiamersmann Dec 2, 2024
fd61271
🔨 replace slope chart icon
sophiamersmann Dec 2, 2024
6839132
✨ (slope) use short name of entity
sophiamersmann Dec 2, 2024
f62418e
🔨 (slope) refactor & small adjustments
sophiamersmann Dec 3, 2024
cf1b51b
🐛 fix type error
sophiamersmann Dec 3, 2024
8cb82cb
🐛 (slope) make layout less jumpy
sophiamersmann Dec 3, 2024
5376452
🐛 (slope) avoid visible overlap when the slope is transparent
sophiamersmann Dec 3, 2024
6491265
🔨 (slope) rephrase fail message
sophiamersmann Dec 3, 2024
2560f1b
✨ (slope) slow down animation
sophiamersmann Dec 3, 2024
2ecbb0d
🔨 (slope) update fail message
sophiamersmann Dec 3, 2024
4289847
🐛 (slope) hide the No Data section for svg exports
sophiamersmann Dec 4, 2024
0443188
💄 fix eslint
sophiamersmann Dec 4, 2024
704fcb3
🔨 update icon for slope charts
sophiamersmann Dec 5, 2024
f25f242
🐛 (line legend) use current background colour for halos
sophiamersmann Dec 5, 2024
d4bac0a
✨ (slope) never show entity labels on the left
sophiamersmann Dec 5, 2024
7d052ad
✨ (slope) pre-filter left labels
sophiamersmann Dec 5, 2024
6e48e8f
✨ (slope) show labels on the left based on level threshold
sophiamersmann Dec 5, 2024
8ceb6c2
✨ (slope) make line legend a bit more narrow in compact mode
sophiamersmann Dec 5, 2024
2a2209e
🔨 (slope) clean up
sophiamersmann Dec 5, 2024
cd0c41d
✨ (slope) prefer to show left labels for entities that are already la…
sophiamersmann Dec 6, 2024
9e470aa
🔨 (slope) clean up
sophiamersmann Dec 6, 2024
00520ee
🐛 fix text wrap group edge case
sophiamersmann Dec 10, 2024
2d3551d
✨ address pr feedback
sophiamersmann Dec 10, 2024
83a376d
🐛 show halos for text wrap group
sophiamersmann Dec 10, 2024
342f583
✨ update No Data section style
sophiamersmann Dec 10, 2024
c9aef10
🔨 deduplicate import statements
sophiamersmann Dec 10, 2024
f6dc632
🐛 make label bold when value is shown
sophiamersmann Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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" },
sophiamersmann marked this conversation as resolved.
Show resolved Hide resolved
],
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
Loading