Skip to content

Commit

Permalink
🔨 (line legend) refactor label dropping algorithm (#4310)
Browse files Browse the repository at this point in the history
Refactors the code that is responsible for dropping labels if there is not enough space.

The SVG tester comes back with one additional difference. It's a slope charts where the 'Tanzania' and 'Mali' labels are swapped. Tanzania should be on top (its value is 1339) and 'Mali' should be below (its value is 1337), so the updated chart is correct.
  • Loading branch information
sophiamersmann authored Dec 19, 2024
1 parent 332107f commit c906407
Show file tree
Hide file tree
Showing 7 changed files with 486 additions and 217 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@

import { PartialBy } from "@ourworldindata/utils"
import { AxisConfig } from "../axis/AxisConfig"
import {
LEGEND_ITEM_MIN_SPACING,
LineLabelSeries,
LineLegend,
} from "./LineLegend"
import { LineLabelSeries, LineLegend } from "./LineLegend"
import { LEGEND_ITEM_MIN_SPACING } from "./LineLegendConstants"

const makeAxis = ({
min = 0,
Expand Down Expand Up @@ -94,8 +91,8 @@ describe("dropping labels", () => {

// 'Democratic Republic of Congo' is skipped since it doesn't fit
expect(lineLegend.visibleSeriesNames).toEqual([
"Mexico",
"Canada",
"Mexico",
"Spain",
])
})
Expand Down Expand Up @@ -164,6 +161,28 @@ describe("dropping labels", () => {
expect(lineLegend.visibleSeriesNames).toEqual(["Canada", "France"])
})

it("picks labels from the edges, skipping long labels", () => {
const series = makeSeries([
{ seriesName: "United States of America", yValue: 5 },
{ seriesName: "Canada", yValue: 10 },
{ seriesName: "Mexico", yValue: 50 },
{ seriesName: "Democratic Republic of Congo", yValue: 90 },
])

const lineLegend = new LineLegend({
series,
maxWidth: 100,
yAxis: makeAxis({ yRange: [0, 60] }),
})

// the two outermost labels don't fit both into the available space.
// so 'Canada' is picked instead of 'United States of America'
expect(lineLegend.visibleSeriesNames).toEqual([
"Canada",
"Democratic Republic of Congo",
])
})

it("picks labels in a balanced way", () => {
const series = makeSeries([
{ seriesName: "Canada", yValue: 10 },
Expand Down
231 changes: 27 additions & 204 deletions packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@ import {
max,
min,
sortBy,
sumBy,
makeIdForHumanConsumption,
excludeUndefined,
sortedIndexBy,
last,
maxBy,
partition,
sumBy,
} from "@ourworldindata/utils"
import { TextWrap, TextWrapGroup, Halo } from "@ourworldindata/components"
import { computed } from "mobx"
Expand All @@ -30,23 +26,19 @@ import { BASE_FONT_SIZE, GRAPHER_FONT_SCALE_12 } from "../core/GrapherConstants"
import { ChartSeries } from "../chart/ChartInterface"
import { darkenColorForText } from "../color/ColorUtils"
import { AxisConfig } from "../axis/AxisConfig.js"
import { GRAPHER_BACKGROUND_DEFAULT, GRAY_30 } from "../color/ColorConstants"
import {
findImportantSeriesThatFitIntoTheAvailableSpace,
findSeriesThatFitIntoTheAvailableSpace,
} from "./LineLegendFilterAlgorithms.js"
import {
GRAPHER_BACKGROUND_DEFAULT,
GRAY_30,
GRAY_70,
} from "../color/ColorConstants"

// text color for labels of background series
const NON_FOCUSED_TEXT_COLOR = GRAY_70
// Minimum vertical space between two legend items
export 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 = 1

const DEFAULT_CONNECTOR_LINE_WIDTH = 25
const DEFAULT_FONT_WEIGHT = 400
ANNOTATION_PADDING,
DEFAULT_CONNECTOR_LINE_WIDTH,
DEFAULT_FONT_WEIGHT,
LEGEND_ITEM_MIN_SPACING,
MARKER_MARGIN,
NON_FOCUSED_TEXT_COLOR,
} from "./LineLegendConstants.js"

export interface LineLabelSeries extends ChartSeries {
label: string
Expand Down Expand Up @@ -664,195 +656,26 @@ export class LineLegend extends React.Component<LineLegendProps> {
}

@computed get visiblePlacedSeries(): PlacedSeries[] {
const { legendY } = this
const { initialPlacedSeries, seriesSortedByImportance, legendY } = this
const availableHeight = Math.abs(legendY[1] - legendY[0])
const nonOverlappingMinHeight = this.computeHeight(
this.initialPlacedSeries
)
const totalHeight = this.computeHeight(initialPlacedSeries)

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

if (this.seriesSortedByImportance) {
// 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.seriesSortedByImportance) {
// if the candidate is the first one, don't add padding
const padding =
keepSeries.length === 0 ? 0 : LEGEND_ITEM_MIN_SPACING
const newHeight =
keepSeriesHeight + series.bounds.height + padding
if (newHeight <= availableHeight) {
keepSeries.push(series)
keepSeriesHeight = newHeight
if (keepSeriesHeight > availableHeight) break
}
}
return keepSeries
} else {
const candidates = new Set<PlacedSeries>(this.initialPlacedSeries)
const sortedKeepSeries: PlacedSeries[] = []

let keepSeriesHeight = 0

const maybePickCandidate = (candidate: PlacedSeries): boolean => {
// if the candidate is the first one, don't add padding
const padding =
sortedKeepSeries.length === 0 ? 0 : LEGEND_ITEM_MIN_SPACING
const newHeight =
keepSeriesHeight + candidate.bounds.height + padding
if (newHeight <= availableHeight) {
const insertIndex = sortedIndexBy(
sortedKeepSeries,
candidate,
(s) => s.midY
)
sortedKeepSeries.splice(insertIndex, 0, candidate)
candidates.delete(candidate)
keepSeriesHeight = newHeight
return true
}
return false
}
if (totalHeight <= availableHeight) return initialPlacedSeries

type Bracket = [number, number]
const findBracket = (
sortedBrackets: Bracket[],
n: number
): [number | undefined, number | undefined] => {
if (sortedBrackets.length === 0) return [undefined, undefined]

const firstBracketValue = sortedBrackets[0][0]
const lastBracketValue = last(sortedBrackets)![1]

if (n < firstBracketValue) return [undefined, firstBracketValue]
if (n >= lastBracketValue) return [lastBracketValue, undefined]

for (const bracket of sortedBrackets) {
if (n >= bracket[0] && n < bracket[1]) return bracket
}

return [undefined, undefined]
}

const [focusedCandidates, nonFocusedCandidates] = partition(
this.initialPlacedSeries,
(series) => series.focus?.active
// if a list of series sorted by importance is provided, use it
if (seriesSortedByImportance) {
return findImportantSeriesThatFitIntoTheAvailableSpace(
seriesSortedByImportance,
availableHeight
)

// pick focused canidates first
while (focusedCandidates.length > 0) {
const focusedCandidate = focusedCandidates.pop()!
const picked = maybePickCandidate(focusedCandidate)

// if one of the focused candidates doesn't fit,
// remove it from the candidates and continue
if (!picked) candidates.delete(focusedCandidate)
}

// we initially need to pick at least two candidates.
// - if we already picked two from the set of focused series,
// we're done
// - if we picked only one focused series, then we pick another
// one from the set of non-focused series. we pick the one that
// is furthest away from the focused one
// - if we haven't picked any focused series, we pick two from
// the non-focused series, one from the top and one from the bottom
if (sortedKeepSeries.length === 0) {
// sort the remaining candidates by their position
const sortedCandidates = sortBy(
nonFocusedCandidates,
(c) => c.midY
)

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

while (nonFocusedCandidates.length > 0) {
// prefer the candidate that is furthest away from the one
// that was already picked
const candidate = maxBy(nonFocusedCandidates, (c) =>
Math.abs(c.midY - keepMidY)
)!
const cIndex = nonFocusedCandidates.indexOf(candidate)
if (cIndex > -1) nonFocusedCandidates.splice(cIndex, 1)

// we only need one more candidate, so if we find one, we're done
const picked = maybePickCandidate(candidate)
if (picked) break

// if the candidate wasn't picked, remove it from the
// candidates and continue
candidates.delete(candidate)
}
}

while (candidates.size > 0 && keepSeriesHeight <= availableHeight) {
const sortedBrackets = sortedKeepSeries
.slice(0, -1)
.map((s, i) => [s.midY, sortedKeepSeries[i + 1].midY])
.filter((bracket) => bracket[0] !== bracket[1]) as Bracket[]

// score each candidate based on how well it fits into the available space
const candidateScores: [PlacedSeries, number][] = Array.from(
candidates
).map((candidate) => {
// find the bracket that the candidate is contained in
const [start, end] = findBracket(
sortedBrackets,
candidate.midY
)
// if no bracket is found, return the worst possible score
if (end === undefined || start === undefined)
return [candidate, 0]

// score the candidate based on how far it is from the
// middle of the bracket and how large the bracket is
const length = end - start
const midPoint = start + length / 2
const distanceFromMidPoint = Math.abs(
candidate.midY - midPoint
)
const score = length - distanceFromMidPoint

return [candidate, score]
})

// pick the candidate with the highest score
// that fits into the available space
let picked = false
while (!picked && candidateScores.length > 0) {
const maxCandidateArr = maxBy(candidateScores, (s) => s[1])!
const maxCandidate = maxCandidateArr[0]
picked = maybePickCandidate(maxCandidate)

// if the highest scoring candidate doesn't fit,
// remove it from the candidates and continue
if (!picked) {
candidates.delete(maxCandidate)

const cIndex = candidateScores.indexOf(maxCandidateArr)
if (cIndex > -1) candidateScores.splice(cIndex, 1)
}
}
}

return sortedKeepSeries
}

// otherwise use the default filtering
return findSeriesThatFitIntoTheAvailableSpace(
initialPlacedSeries,
availableHeight
)
}

@computed get visibleSeriesNames(): SeriesName[] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { GRAY_70 } from "../color/ColorConstants.js"

// text color for labels of background series
export const NON_FOCUSED_TEXT_COLOR = GRAY_70
// Minimum vertical space between two legend items
export const LEGEND_ITEM_MIN_SPACING = 4
// Horizontal distance from the end of the chart to the start of the marker
export const MARKER_MARGIN = 4
// Space between the label and the annotation
export const ANNOTATION_PADDING = 1

export const DEFAULT_CONNECTOR_LINE_WIDTH = 25
export const DEFAULT_FONT_WEIGHT = 400
Loading

0 comments on commit c906407

Please sign in to comment.