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

🔨 (line legend) refactor label dropping algorithm #4310

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading