Skip to content

Commit

Permalink
✨ (grapher) improve line legend algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
sophiamersmann committed May 28, 2024
1 parent 6c73dfc commit 2373e42
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 64 deletions.
216 changes: 154 additions & 62 deletions packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// This implements the line labels that appear to the right of the lines/polygons in LineCharts/StackedAreas.
import React from "react"
import { standardDeviation } from "simple-statistics"
import {
Bounds,
noop,
Expand All @@ -10,6 +11,7 @@ import {
sumBy,
flatten,
makeIdForHumanConsumption,
excludeUndefined,
} from "@ourworldindata/utils"
import { TextWrap } from "@ourworldindata/components"
import { computed } from "mobx"
Expand Down Expand Up @@ -44,10 +46,10 @@ interface SizedSeries extends LineLabelSeries {
interface PlacedSeries extends SizedSeries {
origBounds: Bounds
bounds: Bounds
isOverlap: boolean
repositions: number
level: number
totalLevels: number
midY: number
}

function groupBounds(group: PlacedSeries[]): Bounds {
Expand Down Expand Up @@ -106,6 +108,7 @@ class Label extends React.Component<{
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={onClick}
style={{ cursor: "default" }}
>
{needsLines && (
<g
Expand Down Expand Up @@ -168,6 +171,8 @@ export interface LineLegendManager {
focusedSeriesNames: EntityName[]
yAxis: VerticalAxis
lineLegendX?: number
// used to determine which series should be labelled when there is limited space
seriesSortedByImportance?: EntityName[]
}

@observer
Expand Down Expand Up @@ -256,48 +261,54 @@ export class LineLegend extends React.Component<{
const { yAxis } = this.manager
const { legendX } = this

return sortBy(
this.sizedLabels.map((label) => {
// place vertically centered at Y value
const initialY = yAxis.place(label.yValue) - label.height / 2
const origBounds = new Bounds(
legendX,
initialY,
label.width,
label.height
)
return this.sizedLabels.map((label) => {
// place vertically centered at Y value
const midY = yAxis.place(label.yValue)
const initialY = midY - label.height / 2
const origBounds = new Bounds(
legendX,
initialY,
label.width,
label.height
)

// ensure label doesn't go beyond the top or bottom of the chart
const y = Math.min(
Math.max(initialY, yAxis.rangeMin),
yAxis.rangeMax - label.height
)
const bounds = new Bounds(legendX, y, label.width, label.height)

// ensure label doesn't go beyond the top or bottom of the chart
const y = Math.min(
Math.max(initialY, yAxis.rangeMin),
yAxis.rangeMax - label.height
)
const bounds = new Bounds(legendX, y, label.width, label.height)

return {
...label,
y,
origBounds,
bounds,
isOverlap: false,
repositions: 0,
level: 0,
totalLevels: 0,
}
return {
...label,
y,
midY,
origBounds,
bounds,
repositions: 0,
level: 0,
totalLevels: 0,
}
})
}

// Ensure list is sorted by the visual position in ascending order
}),
(label) => yAxis.place(label.yValue)
)
@computed get intialSeriesByName(): Map<EntityName, PlacedSeries> {
return new Map(this.initialSeries.map((d) => [d.seriesName, d]))
}

@computed get standardPlacement(): PlacedSeries[] {
@computed get placedSeries(): PlacedSeries[] {
const { yAxis } = this.manager

const groups: PlacedSeries[][] = cloneDeep(this.initialSeries).map(
(mark) => [mark]
// ensure list is sorted by the visual position in ascending order
const sortedSeries = sortBy(
this.partialInitialSeries,
(label) => label.midY
)

const groups: PlacedSeries[][] = cloneDeep(sortedSeries).map((mark) => [
mark,
])

let hasOverlap

do {
Expand Down Expand Up @@ -360,49 +371,130 @@ export class LineLegend extends React.Component<{
return flatten(groups)
}

// Overlapping placement, for when we really can't find a solution without overlaps.
@computed get overlappingPlacement(): PlacedSeries[] {
const series = cloneDeep(this.initialSeries)
for (let i = 0; i < series.length; i++) {
const m1 = series[i]

for (let j = i + 1; j < series.length; j++) {
const m2 = series[j]
const isOverlap =
!m1.isOverlap && m1.bounds.intersects(m2.bounds)
if (isOverlap) m2.isOverlap = true
}
}
return series
@computed get sortedSeriesByImportance(): PlacedSeries[] | undefined {
if (!this.manager.seriesSortedByImportance) return undefined
return excludeUndefined(
this.manager.seriesSortedByImportance.map((seriesName) =>
this.intialSeriesByName.get(seriesName)
)
)
}

@computed get placedSeries(): PlacedSeries[] {
@computed get partialInitialSeries(): PlacedSeries[] {
const availableHeight = this.manager.yAxis.rangeSize
const nonOverlappingMinHeight =
sumBy(this.initialSeries, (series) => series.bounds.height) +
this.initialSeries.length * LEGEND_ITEM_MIN_SPACING
const availableHeight = this.manager.yAxis.rangeSize
if (nonOverlappingMinHeight > availableHeight)
return this.overlappingPlacement
return this.standardPlacement

// early return if filtering is not needed
if (nonOverlappingMinHeight <= availableHeight)
return this.initialSeries

if (this.sortedSeriesByImportance) {
// keep a subset of series that fit within the available height,
// prioritizing by importance. Note that more important (but longer)
// series names are skipped if they don't fit.
const keepSeries: PlacedSeries[] = []
let keepSeriesHeight = 0
for (const series of this.sortedSeriesByImportance) {
const newHeight =
keepSeriesHeight +
series.bounds.height +
LEGEND_ITEM_MIN_SPACING
if (newHeight <= availableHeight) {
keepSeries.push(series)
keepSeriesHeight = newHeight
if (keepSeriesHeight > availableHeight) break
}
}
return keepSeries
} else {
const candidates = new Set<PlacedSeries>(this.initialSeries)
const keepSeries: PlacedSeries[] = []

let keepSeriesHeight = 0

const maybePickCandidate = (candidate: PlacedSeries): boolean => {
const newHeight =
keepSeriesHeight +
candidate.bounds.height +
LEGEND_ITEM_MIN_SPACING
if (newHeight <= availableHeight) {
keepSeries.push(candidate)
candidates.delete(candidate)
keepSeriesHeight = newHeight
return true
}
return false
}

const sortedCandidatesByPosition = sortBy(
this.initialSeries,
(c) => c.midY
)

// pick two candidates, one from the top and one from the bottom
const midIndex = Math.floor(
(sortedCandidatesByPosition.length - 1) / 2
)
for (let startIndex = 0; startIndex <= midIndex; startIndex++) {
const endIndex =
sortedCandidatesByPosition.length - 1 - startIndex
maybePickCandidate(sortedCandidatesByPosition[endIndex])
if (keepSeries.length >= 2 || startIndex === endIndex) break
maybePickCandidate(sortedCandidatesByPosition[startIndex])
if (keepSeries.length >= 2) break
}

while (candidates.size > 0 && keepSeriesHeight <= availableHeight) {
const candidateArray = Array.from(candidates)
const stds: [PlacedSeries, number][] = Array.from({
length: candidates.size,
})

// compute standard deviation of distances as a measure of
// how balanced the labelling would be if the candidate were picked
for (let i = 0; i < candidateArray.length; i++) {
stds[i] = [
candidateArray[i],
standardDeviation(
keepSeries.map((s) =>
Math.abs(s.midY - candidateArray[i].midY)
)
),
]
}

// pick a candidate with a small standard deviation
// that fits into the available space
const sortedCandidates = sortBy(stds, (s) => s[1])
let foundCandidate = false
for (const [candidate] of sortedCandidates) {
foundCandidate = maybePickCandidate(candidate)
if (foundCandidate) break
}
if (!foundCandidate) break
}

return keepSeries
}
}

@computed private get backgroundSeries(): PlacedSeries[] {
const { focusedSeriesNames } = this.manager
const { isFocusMode } = this
return this.placedSeries.filter((mark) =>
isFocusMode
? !focusedSeriesNames.includes(mark.seriesName)
: mark.isOverlap
return this.placedSeries.filter(
(mark) =>
isFocusMode && !focusedSeriesNames.includes(mark.seriesName)
)
}

@computed private get focusedSeries(): PlacedSeries[] {
const { focusedSeriesNames } = this.manager
const { isFocusMode } = this
return this.placedSeries.filter((mark) =>
isFocusMode
? focusedSeriesNames.includes(mark.seriesName)
: !mark.isOverlap
return this.placedSeries.filter(
(mark) =>
!isFocusMode || focusedSeriesNames.includes(mark.seriesName)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
Time,
lastOfNonEmptyArray,
makeIdForHumanConsumption,
maxBy,
sumBy,
} from "@ourworldindata/utils"
import { computed, action, observable } from "mobx"
import { SeriesName } from "@ourworldindata/types"
Expand Down Expand Up @@ -313,18 +315,65 @@ export class StackedAreaChart
}

@observable hoverSeriesName?: SeriesName
@observable private hoverTimer?: NodeJS.Timeout

@computed protected get paddingForLegend(): number {
const { legendDimensions } = this
return legendDimensions ? legendDimensions.width : 0
}

@computed get seriesSortedByImportance(): string[] {
return [...this.series]
.sort(
(
s1: StackedSeries<number>,
s2: StackedSeries<number>
): number => {
const PREFER_S1 = -1
const PREFER_S2 = 1

if (!s1) return PREFER_S2
if (!s2) return PREFER_S1

// early return if one series is all zeroes
if (s1.isAllZeros && !s2.isAllZeros) return PREFER_S2
if (s2.isAllZeros && !s1.isAllZeros) return PREFER_S1

// prefer series with a higher maximum value
const yMax1 = maxBy(s1.points, (p) => p.value)?.value ?? 0
const yMax2 = maxBy(s2.points, (p) => p.value)?.value ?? 0
if (yMax1 > yMax2) return PREFER_S1
if (yMax2 > yMax1) return PREFER_S2

// prefer series with a higher last value
const yLast1 = last(s1.points)?.value ?? 0
const yLast2 = last(s2.points)?.value ?? 0
if (yLast1 > yLast2) return PREFER_S1
if (yLast2 > yLast1) return PREFER_S2

// prefer series with a higher total area
const area1 = sumBy(s1.points, (p) => p.value)
const area2 = sumBy(s2.points, (p) => p.value)
if (area1 > area2) return PREFER_S1
if (area2 > area1) return PREFER_S2

return 0
}
)
.map((s) => s.seriesName)
}

@action.bound onLineLegendMouseOver(seriesName: SeriesName): void {
clearTimeout(this.hoverTimer)
this.hoverSeriesName = seriesName
}

@action.bound onLineLegendMouseLeave(): void {
this.hoverSeriesName = undefined
@action.bound clearHighlightedSeries(): void {
clearTimeout(this.hoverTimer)
this.hoverTimer = setTimeout(() => {
// wait before clearing selection in case the mouse is moving quickly over neighboring labels
this.hoverSeriesName = undefined
}, 200)
}

@computed get focusedSeriesNames(): string[] {
Expand Down

0 comments on commit 2373e42

Please sign in to comment.