diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx index 0d3a3d8180..c2be72ef40 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 fef2260f4a..f36393b9e9 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 0000000000..7d2f8d2df8 --- /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 0000000000..e8c68f1dcf --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendFilterAlgorithms.ts @@ -0,0 +1,136 @@ +import { maxBy, partition } from "@ourworldindata/utils" +import { + computeCandidateScores, + LineLegendFilterAlgorithmContext, + pickAsManyAsPossible, + pickCandidateWithMaxDistanceToReferenceCandidate, + pickFixedNumber, + pickTwoCandidatesWithMaxDistanceToEachOther, +} 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 pop = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => importanceScore.get(c.seriesName)) + + // focused series have priority + context = pickAsManyAsPossible({ + context, + candidateSubset: focusedCandidates, + popCandidateFromSubset: pop, + }) + + context = pickAsManyAsPossible({ + context, + candidateSubset: nonFocusedCandidates, + popCandidateFromSubset: pop, + }) + + 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 = pickAsManyAsPossible({ + context, + candidateSubset: focusedCandidates, + }) + + // we initially need to pick at least two candidates + const numPickedCandidates = context.sortedKeepSeries.length + if (numPickedCandidates === 0) { + // pick the two outermost candidates + context = pickTwoCandidatesWithMaxDistanceToEachOther({ + context, + candidateSubset: nonFocusedCandidates, + }) + } 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 pop = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => scoreMap.get(c.seriesName)) + + context = pickFixedNumber({ + context, + candidateSubset: candidates, + popCandidateFromSubset: pop, + maxCandidatesToPick: 1, + }) + } + + 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 0000000000..2db0e5d64c --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegendHelpers.ts @@ -0,0 +1,290 @@ +import { + last, + maxBy, + minBy, + 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[] + popCandidateFromSubset?: ( + candidateSubset: PlacedSeries[], + context: LineLegendFilterAlgorithmContext + ) => PlacedSeries | undefined + maxCandidatesToPick?: number +} + +export 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. + */ +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 + * `popCandidateFromSubset` 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 pickFromCandidateSubset( + params: PickFromCandidateSubsetParams +): LineLegendFilterAlgorithmContext { + let { + context, + candidateSubset, + popCandidateFromSubset, + maxCandidatesToPick, + } = params + + if (candidateSubset.length === 0 || maxCandidatesToPick === 0) + return context + + const remainingCandidates = [...candidateSubset] + let numPicked = 0 + + // if a custom function to pop candidates is provided, use it + // otherwise, pop the last candidate + const popCandidate = (): PlacedSeries | undefined => { + if (popCandidateFromSubset) { + const candidate = popCandidateFromSubset( + remainingCandidates, + context + ) + if (candidate) { + const index = remainingCandidates.indexOf(candidate) + remainingCandidates.splice(index, 1) + } + return candidate + } + + return remainingCandidates.pop() + } + + while (remainingCandidates.length > 0) { + const candidate = popCandidate() + if (!candidate) break + + // 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 + * `popCandidateFromSubset` 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 pickAsManyAsPossible( + params: Omit +): LineLegendFilterAlgorithmContext { + return pickFromCandidateSubset(params) +} + +/** + * Pick a fixed number of candidates from a give subset. + * + * The order of candidates to consider for placement is determined by the + * `popCandidateFromSubset` 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 pickFixedNumber( + params: Required +): LineLegendFilterAlgorithmContext { + return pickFromCandidateSubset(params) +} + +export function pickCandidateWithMaxDistanceToReferenceCandidate(params: { + context: LineLegendFilterAlgorithmContext + candidateSubset: PlacedSeries[] + referenceCandidate: PlacedSeries +}): LineLegendFilterAlgorithmContext { + const { context, candidateSubset, referenceCandidate } = params + const distanceMap = new Map( + candidateSubset.map((c) => [ + c.seriesName, + Math.abs(c.midY - referenceCandidate.midY), + ]) + ) + const pop = (candidates: PlacedSeries[]) => + maxBy(candidates, (c) => distanceMap.get(c.seriesName)) + + return pickFixedNumber({ + context, + candidateSubset, + popCandidateFromSubset: pop, + maxCandidatesToPick: 1, + }) +} + +export function pickTwoCandidatesWithMaxDistanceToEachOther(params: { + context: LineLegendFilterAlgorithmContext + candidateSubset: PlacedSeries[] +}): LineLegendFilterAlgorithmContext { + const { context, candidateSubset } = params + + const pop = ( + candidates: PlacedSeries[], + context: LineLegendFilterAlgorithmContext + ) => { + if (context.sortedKeepSeries.length === 0) { + // try a candidate from the top, then from the bottom, then from the top again, etc. + return candidates.length % 2 === 0 + ? maxBy(candidates, (c) => c.midY) + : minBy(candidates, (c) => c.midY) + } else { + // once we have one candidate, pick another one that is furthest away from it + return maxBy(candidates, (c) => + Math.abs(c.midY - context.sortedKeepSeries[0].midY) + ) + } + } + + return pickFixedNumber({ + context, + candidateSubset, + popCandidateFromSubset: pop, + maxCandidatesToPick: 2, + }) +} + +/** + * 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 0000000000..59905fa108 --- /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 fe4e70e094..3d01ce3607 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)