From 2373e42c10abd8e3477719f65847d4832135a3dc Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Fri, 24 May 2024 15:09:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(grapher)=20improve=20line=20legend?= =?UTF-8?q?=20algorithm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/lineLegend/LineLegend.tsx | 216 +++++++++++++----- .../src/stackedCharts/StackedAreaChart.tsx | 53 ++++- 2 files changed, 205 insertions(+), 64 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 0f362c788df..959fa1341ee 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -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, @@ -10,6 +11,7 @@ import { sumBy, flatten, makeIdForHumanConsumption, + excludeUndefined, } from "@ourworldindata/utils" import { TextWrap } from "@ourworldindata/components" import { computed } from "mobx" @@ -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 { @@ -106,6 +108,7 @@ class Label extends React.Component<{ onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} onClick={onClick} + style={{ cursor: "default" }} > {needsLines && ( { - // 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 { + 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 { @@ -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(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) ) } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index fd4bc43d667..10bc0e811c9 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -13,6 +13,8 @@ import { Time, lastOfNonEmptyArray, makeIdForHumanConsumption, + maxBy, + sumBy, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" import { SeriesName } from "@ourworldindata/types" @@ -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, + s2: StackedSeries + ): 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[] {