From c906407e3535fcac344ed0424bc4bfa5e362250d Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Thu, 19 Dec 2024 13:15:33 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20(line=20legend)=20refactor=20lab?= =?UTF-8?q?el=20dropping=20algorithm=20(#4310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/lineLegend/LineLegend.test.tsx | 31 ++- .../grapher/src/lineLegend/LineLegend.tsx | 231 ++-------------- .../src/lineLegend/LineLegendConstants.ts | 13 + .../lineLegend/LineLegendFilterAlgorithms.ts | 143 ++++++++++ .../src/lineLegend/LineLegendHelpers.ts | 247 ++++++++++++++++++ .../grapher/src/lineLegend/LineLegendTypes.ts | 31 +++ .../grapher/src/slopeCharts/SlopeChart.tsx | 7 - 7 files changed, 486 insertions(+), 217 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/lineLegend/LineLegendConstants.ts create mode 100644 packages/@ourworldindata/grapher/src/lineLegend/LineLegendFilterAlgorithms.ts create mode 100644 packages/@ourworldindata/grapher/src/lineLegend/LineLegendHelpers.ts create mode 100644 packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx index 0d3a3d81805..c2be72ef40b 100755 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx @@ -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, @@ -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", ]) }) @@ -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 }, diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index fef2260f4a9..f36393b9e96 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -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" @@ -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 @@ -664,195 +656,26 @@ export class LineLegend extends React.Component { } @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(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[] { diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendConstants.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendConstants.ts new file mode 100644 index 00000000000..7d2f8d2df82 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendConstants.ts @@ -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 diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendFilterAlgorithms.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendFilterAlgorithms.ts new file mode 100644 index 00000000000..742a1aef9ad --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendFilterAlgorithms.ts @@ -0,0 +1,143 @@ +import { maxBy, partition } from "@ourworldindata/utils" +import { + computeCandidateScores, + LineLegendFilterAlgorithmContext, + pickAsManyAsPossibleWithRetry, + pickCandidate, + pickCandidateWithMaxDistanceToReferenceCandidate, + pickCandidateWithRetry, +} from "./LineLegendHelpers" +import { PlacedSeries } from "./LineLegendTypes" + +/** + * Keep a subset of series that fit within the available height, prioritizing by + * importance. Focused series have priority, even if they're less important. + * + * Note that more important (but longer) series names might be skipped if they don't fit. + */ +export function findImportantSeriesThatFitIntoTheAvailableSpace( + seriesSortedByImportance: PlacedSeries[], + availableHeight: number +) { + let context: LineLegendFilterAlgorithmContext = { + candidates: new Set(seriesSortedByImportance), + availableHeight, + sortedKeepSeries: [], + keepSeriesHeight: 0, + } + + const [focusedCandidates, nonFocusedCandidates] = partition( + seriesSortedByImportance, + (series) => series.focus?.active + ) + + const importanceScore = new Map( + seriesSortedByImportance.map((series, index) => [ + series.seriesName, + -index, // higher index means lower importance + ]) + ) + + const getMostImportantCandidate = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => importanceScore.get(c.seriesName)) + + // focused series have priority + context = pickAsManyAsPossibleWithRetry({ + context, + candidateSubset: focusedCandidates, + getCandidateFromSubset: getMostImportantCandidate, + }) + + context = pickAsManyAsPossibleWithRetry({ + context, + candidateSubset: nonFocusedCandidates, + getCandidateFromSubset: getMostImportantCandidate, + }) + + return context.sortedKeepSeries +} + +/** + * Pick a subset of series that fit within the available height. + * + * The algorithm tries to pick labels in a 'balanced' way such that they're + * spread out as much as possible. Focused series have priority. + * + * The algorithm works as follows: Given a set of placed labels and a set of + * candidates, for each candidate, we find the two closest already placed labels, + * one to each side, and calculate a score based on the available space between + * the two placed labels (the bigger, the better) and the candidate's distance to + * the midpoint (the smaller, the better). We then pick the candidate with the best + * score that fits into the available space. + */ +export function findSeriesThatFitIntoTheAvailableSpace( + series: PlacedSeries[], + availableHeight: number +): PlacedSeries[] { + let context: LineLegendFilterAlgorithmContext = { + candidates: new Set(series), + availableHeight, + sortedKeepSeries: [], + keepSeriesHeight: 0, + } + + const [focusedCandidates, nonFocusedCandidates] = partition( + series, + (series) => series.focus?.active + ) + + // focused series have priority + context = pickAsManyAsPossibleWithRetry({ + context, + candidateSubset: focusedCandidates, + }) + + // we initially need to pick at least two candidates + const numPickedCandidates = context.sortedKeepSeries.length + if (numPickedCandidates === 0) { + // pick two candidates with maximal distance to each other. + // by convention we pick the max candidate first, but we could also + // start by picking the min cadidate + const maxCandidate = maxBy(nonFocusedCandidates, (c) => c.midY) + if (maxCandidate) { + context = pickCandidate(context, maxCandidate) + + context = pickCandidateWithMaxDistanceToReferenceCandidate({ + context, + candidateSubset: nonFocusedCandidates, + referenceCandidate: context.sortedKeepSeries[0], + }) + } + } else if (numPickedCandidates === 1) { + // pick the candidate that is furthest away from the focused label + context = pickCandidateWithMaxDistanceToReferenceCandidate({ + context, + candidateSubset: nonFocusedCandidates, + referenceCandidate: context.sortedKeepSeries[0], + }) + } + + // pick candidates based on a scoring system + while ( + context.candidates.size > 0 && + context.keepSeriesHeight <= availableHeight + ) { + const candidates = Array.from(context.candidates) + const scoreMap = computeCandidateScores( + candidates, + context.sortedKeepSeries + ) + + // pick the candidate with the highest score + const getBestCandidate = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => scoreMap.get(c.seriesName)) + + context = pickCandidateWithRetry({ + context, + candidateSubset: candidates, + getCandidateFromSubset: getBestCandidate, + }) + } + + return context.sortedKeepSeries +} diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendHelpers.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendHelpers.ts new file mode 100644 index 00000000000..17310a4d4e6 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendHelpers.ts @@ -0,0 +1,247 @@ +import { last, maxBy, SeriesName, sortedIndexBy } from "@ourworldindata/utils" +import { PlacedSeries } from "./LineLegendTypes" +import { LEGEND_ITEM_MIN_SPACING } from "./LineLegendConstants" + +type Bracket = [number, number] + +export interface LineLegendFilterAlgorithmContext { + candidates: Set // remaining candidates to be considered for placement + availableHeight: number + sortedKeepSeries: PlacedSeries[] // series that have been picked to be labelled, sorted by their y position + keepSeriesHeight: number // total height of the picked series +} + +interface PickFromCandidateSubsetParams { + context: LineLegendFilterAlgorithmContext + candidateSubset: PlacedSeries[] + getCandidateFromSubset?: ( + candidateSubset: PlacedSeries[] + ) => PlacedSeries | undefined +} + +const dist = (c1: PlacedSeries, c2: PlacedSeries) => Math.abs(c1.midY - c2.midY) + +function getNewHeight(currentHeight: number, candidate: PlacedSeries): number { + // if the candidate is the first one, don't add padding + const padding = currentHeight === 0 ? 0 : LEGEND_ITEM_MIN_SPACING + return currentHeight + candidate.bounds.height + padding +} + +/** + * Given a sorted list of brackets, like [[0, 10], [10, 20], [20, 30]], + * find the bracket that contains the given number n. + */ +function 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] +} + +/** + * Add a candidate to the list of picked series and update the context accordingly. + */ +export function pickCandidate( + context: LineLegendFilterAlgorithmContext, + candidate: PlacedSeries +): LineLegendFilterAlgorithmContext { + let { candidates, sortedKeepSeries, keepSeriesHeight } = context + + // insert into sortedKeepSeries at the right position + const insertIndex = sortedIndexBy( + context.sortedKeepSeries, + candidate, + (s) => s.midY + ) + sortedKeepSeries.splice(insertIndex, 0, candidate) + + // update keepSeriesHeight + keepSeriesHeight = getNewHeight(keepSeriesHeight, candidate) + + // delete from candidates + candidates.delete(candidate) + + return { ...context, candidates, sortedKeepSeries, keepSeriesHeight } +} + +/** + * Remove a candidate from the list of candidates to be considered for placement. + */ +function dismissCandidate( + context: LineLegendFilterAlgorithmContext, + candidate: PlacedSeries +) { + const { candidates } = context + candidates.delete(candidate) + return { ...context, candidates } +} + +/** + * Pick from a subset of candidates until one of the following conditions is met: + * - no candidates are left or the maximum number of candidates to pick is reached + * - no more candidates fit into the available space + * + * The order of candidates to consider for placement is determined by the + * `getCandidateFromSubset` function. The function should return the next candidate + * to consider. If the function returns `undefined`, the algorithm stops. + * + * If no custom function is provided, the algorithm picks candidates starting from + * the end (!) of the given list. + */ +function pickFromCandidateSubsetWithRetry( + params: PickFromCandidateSubsetParams & { maxCandidatesToPick?: number } +): LineLegendFilterAlgorithmContext { + let { + context, + candidateSubset, + getCandidateFromSubset, + maxCandidatesToPick, + } = params + + if (candidateSubset.length === 0 || maxCandidatesToPick === 0) + return context + + const remainingCandidates = [...candidateSubset] + let numPicked = 0 + + // if a custom function to get a candidate is provided, use it + // otherwise, pop the last candidate + const getCandidate = (): PlacedSeries | undefined => { + if (getCandidateFromSubset) { + const candidate = getCandidateFromSubset(remainingCandidates) + if (candidate) { + const index = remainingCandidates.indexOf(candidate) + remainingCandidates.splice(index, 1) + } + return candidate + } + + return remainingCandidates.pop() + } + + while (remainingCandidates.length > 0) { + const candidate = getCandidate() + if (!candidate) break + + // sanity check if this is a valid candidate + if (!context.candidates.has(candidate)) continue + + // either pick or dismiss the candidate + const newHeight = getNewHeight(context.keepSeriesHeight, candidate) + if (newHeight <= context.availableHeight) { + context = pickCandidate(context, candidate) + numPicked++ + } else { + context = dismissCandidate(context, candidate) + } + + // stop if we picked enough candidates + if (numPicked === maxCandidatesToPick) break + } + + return context +} + +/** + * Pick as many candidates as possible from a given subset. + * + * The order of candidates to consider for placement is determined by the + * `getCandidateFromSubset` function. The function should return the next candidate + * to consider. If the function returns `undefined`, the algorithm stops. + * + * If no custom function is provided, the algorithm picks candidates starting from + * the end (!) of the given list. + */ +export function pickAsManyAsPossibleWithRetry( + params: PickFromCandidateSubsetParams +): LineLegendFilterAlgorithmContext { + return pickFromCandidateSubsetWithRetry(params) +} + +/** + * Pick a fixed number of candidates from a give subset. + * + * The order of candidates to consider for placement is determined by the + * `getCandidateFromSubset` function. The function should return the next candidate + * to consider. If the function returns `undefined`, the algorithm stops. + * + * If no custom function is provided, the algorithm picks candidates starting from + * the end (!) of the given list. + */ +export function pickCandidateWithRetry( + params: PickFromCandidateSubsetParams +): LineLegendFilterAlgorithmContext { + return pickFromCandidateSubsetWithRetry({ + ...params, + maxCandidatesToPick: 1, + }) +} + +export function pickCandidateWithMaxDistanceToReferenceCandidate(params: { + context: LineLegendFilterAlgorithmContext + candidateSubset: PlacedSeries[] + referenceCandidate: PlacedSeries +}): LineLegendFilterAlgorithmContext { + const { context, candidateSubset, referenceCandidate } = params + + const getMaxDistCandidate = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => dist(c, referenceCandidate)) + + return pickCandidateWithRetry({ + context, + candidateSubset, + getCandidateFromSubset: getMaxDistCandidate, + }) +} + +/** + * Compute a score for each candidate based on how large the space between the + * neighboring labels is and how far it is from the mid point of the neighboring + * labels. + */ +export function computeCandidateScores( + candidates: PlacedSeries[], + sortedKeepSeries: PlacedSeries[] +): Map { + const scoreMap = new Map() + + 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 + for (const candidate of candidates) { + // 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) { + scoreMap.set(candidate.seriesName, 0) + continue + } + + // 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 + + scoreMap.set(candidate.seriesName, score) + } + + return scoreMap +} diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts new file mode 100644 index 00000000000..59905fa1081 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendTypes.ts @@ -0,0 +1,31 @@ +import { TextWrap, TextWrapGroup } from "@ourworldindata/components" +import { Bounds, InteractionState } from "@ourworldindata/utils" +import { ChartSeries } from "../chart/ChartInterface" + +export interface LineLabelSeries extends ChartSeries { + label: string + yValue: number + annotation?: string + formattedValue?: string + placeFormattedValueInNewLine?: boolean + yRange?: [number, number] + hover?: InteractionState + focus?: InteractionState +} + +export interface SizedSeries extends LineLabelSeries { + textWrap: TextWrap | TextWrapGroup + annotationTextWrap?: TextWrap + width: number + height: number + fontWeight?: number +} + +export interface PlacedSeries extends SizedSeries { + origBounds: Bounds + bounds: Bounds + repositions: number + level: number + totalLevels: number + midY: number +} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index fe4e70e094d..3d01ce36072 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -732,13 +732,6 @@ export class SlopeChart const PREFER_S1 = -1 const PREFER_S2 = 1 - const s1_isFocused = this.focusArray.has(s1) - const s2_isFocused = this.focusArray.has(s2) - - // prefer to label focused series - if (s1_isFocused && !s2_isFocused) return PREFER_S1 - if (s2_isFocused && !s1_isFocused) return PREFER_S2 - const s1_isLabelled = this.visibleLineLegendLabelsRight.has(s1) const s2_isLabelled = this.visibleLineLegendLabelsRight.has(s2)