From b0bb26640fa37587456a9f345d9d219552abf058 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Fri, 6 Dec 2024 18:12:41 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20refactor=20hover=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/chart/ChartManager.ts | 1 - .../grapher/src/chart/ChartUtils.tsx | 13 + .../grapher/src/facetChart/FacetChart.tsx | 12 - .../grapher/src/lineCharts/LineChart.tsx | 510 +++++++++--------- .../src/lineCharts/LineChartConstants.ts | 9 +- .../src/lineLegend/LineLegend.test.tsx | 1 - .../grapher/src/lineLegend/LineLegend.tsx | 118 +--- .../src/scatterCharts/ScatterPlotChart.tsx | 35 +- .../grapher/src/selection/InteractionArray.ts | 99 ++++ .../grapher/src/slopeCharts/SlopeChart.tsx | 146 +++-- .../src/slopeCharts/SlopeChartConstants.ts | 10 +- .../stackedCharts/AbstractStackedChart.tsx | 25 +- .../src/stackedCharts/MarimekkoChart.tsx | 18 +- .../src/stackedCharts/StackedAreaChart.tsx | 56 +- .../src/stackedCharts/StackedBarChart.tsx | 80 +-- .../stackedCharts/StackedDiscreteBarChart.tsx | 13 +- .../types/src/grapherTypes/GrapherTypes.ts | 12 +- packages/@ourworldindata/types/src/index.ts | 2 +- 18 files changed, 571 insertions(+), 589 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/selection/InteractionArray.ts diff --git a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts index 8b24bdd70a3..6185c42a4c6 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts @@ -81,7 +81,6 @@ export interface ChartManager { sortConfig?: SortConfig showNoDataArea?: boolean // No data area in Marimekko charts - externalLegendHoverBin?: ColorScaleBin | undefined disableIntroAnimation?: boolean missingDataStrategy?: MissingDataStrategy diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 249920b44f0..88483f10300 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -7,6 +7,7 @@ import { GrapherChartType, GRAPHER_CHART_TYPES, GRAPHER_TAB_QUERY_PARAMS, + InteractionState, } from "@ourworldindata/types" import { LineChartSeries } from "../lineCharts/LineChartConstants" import { SelectionArray } from "../selection/SelectionArray" @@ -188,3 +189,15 @@ export function findValidChartTypeCombination( } return undefined } + +/** Useful for sorting series by their interaction state */ +export function byInteractionState(series: { + hover?: InteractionState +}): number { + // background series first, + // then series in their default state, + // then active series + if (series.hover === InteractionState.background) return 1 + else if (series.hover === InteractionState.active) return 3 + else return 2 +} diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index cc760503661..32bf8d90bad 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -479,7 +479,6 @@ export class FacetChart const manager = { ...series.manager, useValueBasedColorScheme, - externalLegendHoverBin: this.legendHoverBin, xAxisConfig: { // For now, sharing an x axis means hiding the tick labels of inner facets. // This means that none of the x axes are actually hidden (we just don't plot their tick labels). @@ -763,17 +762,6 @@ export class FacetChart return sortedBins } - @observable.ref private legendHoverBin: ColorScaleBin | undefined = - undefined - - @action.bound onLegendMouseOver(bin: ColorScaleBin): void { - this.legendHoverBin = bin - } - - @action.bound onLegendMouseLeave(): void { - this.legendHoverBin = undefined - } - // end of legend props @computed private get legend(): HorizontalColorLegend { diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index d75109dcf9b..3a43925ae15 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -53,6 +53,7 @@ import { ColorScaleConfigInterface, ColorSchemeName, VerticalAlign, + InteractionState, } from "@ourworldindata/types" import { GRAPHER_AXIS_LINE_WIDTH_THICK, @@ -70,6 +71,7 @@ import { LinePoint, PlacedLineChartSeries, PlacedPoint, + RenderLineChartSeries, } from "./LineChartConstants" import { OwidTable, @@ -79,6 +81,7 @@ import { import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + byInteractionState, getDefaultFailMessage, getSeriesKey, isTargetOutsideElement, @@ -105,6 +108,7 @@ import { getColorKey, getSeriesName, } from "./LineChartHelpers" +import { InteractionArray } from "../selection/InteractionArray" const LINE_CHART_CLASS_NAME = "LineChart" @@ -123,237 +127,6 @@ const VARIABLE_COLOR_LINE_OUTLINE_WIDTH = 1.0 // legend const LEGEND_PADDING = 25 -@observer -class Lines extends React.Component { - @computed get bounds(): Bounds { - const { horizontalAxis, verticalAxis } = this.props.dualAxis - return Bounds.fromCorners( - new PointVector(horizontalAxis.range[0], verticalAxis.range[0]), - new PointVector(horizontalAxis.range[1], verticalAxis.range[1]) - ) - } - - @computed private get focusedLines(): PlacedLineChartSeries[] { - const { focusedSeriesNames } = this.props - // If nothing is focused, everything is - if (!focusedSeriesNames.length) return this.props.placedSeries - return this.props.placedSeries.filter((series) => - focusedSeriesNames.includes(series.seriesName) - ) - } - - @computed private get backgroundLines(): PlacedLineChartSeries[] { - const { focusedSeriesNames } = this.props - // if nothing is focused, everything is focused, so nothing is in the background - if (!focusedSeriesNames.length) return [] - return this.props.placedSeries.filter( - (series) => !focusedSeriesNames.includes(series.seriesName) - ) - } - - // Don't display point markers if there are very many of them for performance reasons - // Note that we're using circle elements instead of marker-mid because marker performance in Safari 10 is very poor for some reason - @computed private get hasMarkers(): boolean { - if (this.props.hidePoints) return false - return ( - sum(this.focusedLines.map((series) => series.placedPoints.length)) < - 500 - ) - } - - @computed private get markerRadius(): number { - return this.props.markerRadius ?? DEFAULT_MARKER_RADIUS - } - - @computed private get strokeWidth(): number { - return this.props.lineStrokeWidth ?? DEFAULT_STROKE_WIDTH - } - - @computed private get lineOutlineWidth(): number { - return this.props.lineOutlineWidth ?? DEFAULT_LINE_OUTLINE_WIDTH - } - - private renderPathForSeries( - series: PlacedLineChartSeries, - props: Partial> - ): React.ReactElement { - const strokeDasharray = series.isProjection ? "2,3" : undefined - return ( - [value.x, value.y]) as [ - number, - number, - ][] - )} - /> - ) - } - - private renderFocusLines(): React.ReactElement | void { - if (this.focusedLines.length === 0) return - return ( - - {this.focusedLines.map((series) => { - const strokeDasharray = series.isProjection - ? "2,3" - : undefined - return ( - - {this.renderPathForSeries(series, { - id: makeIdForHumanConsumption( - "outline", - series.seriesName - ), - stroke: - this.props.backgroundColor ?? - GRAPHER_BACKGROUND_DEFAULT, - strokeWidth: - this.strokeWidth + - this.lineOutlineWidth * 2, - })} - {this.props.multiColor ? ( - - ) : ( - this.renderPathForSeries(series, { - id: makeIdForHumanConsumption( - "line", - series.seriesName - ), - stroke: - series.placedPoints[0]?.color ?? - DEFAULT_LINE_COLOR, - }) - )} - - ) - })} - - ) - } - - private renderLineMarkers(): React.ReactElement | void { - const { horizontalAxis } = this.props.dualAxis - if (this.focusedLines.length === 0) return - return ( - - {this.focusedLines.map((series) => { - // If the series only contains one point, then we will always want to show a marker/circle - // because we can't draw a line. - const showMarkers = - (this.hasMarkers || series.placedPoints.length === 1) && - !series.isProjection - return ( - showMarkers && ( - - {series.placedPoints.map((value, index) => ( - - ))} - - ) - ) - })} - - ) - } - - private renderFocusGroups(): React.ReactElement | void { - return ( - <> - {this.renderFocusLines()} - {this.renderLineMarkers()} - - ) - } - - private renderBackgroundGroups(): React.ReactElement | void { - if (this.backgroundLines.length === 0) return - return ( - - {this.backgroundLines.map((series) => ( - - {this.renderPathForSeries(series, { - id: makeIdForHumanConsumption( - "background-line", - series.seriesName - ), - stroke: series.color, - strokeWidth: 1, - strokeOpacity: 0.3, - })} - - ))} - - ) - } - - renderStatic(): React.ReactElement { - return ( - <> - {this.renderBackgroundGroups()} - {this.renderFocusGroups()} - - ) - } - - renderInteractive(): React.ReactElement { - const { bounds } = this - return ( - - - {this.renderBackgroundGroups()} - {this.renderFocusGroups()} - - ) - } - - render(): React.ReactElement { - return this.props.isStatic - ? this.renderStatic() - : this.renderInteractive() - } -} - @observer export class LineChart extends React.Component<{ @@ -368,6 +141,8 @@ export class LineChart { base: React.RefObject = React.createRef() + hoverArray = new InteractionArray() + transformTable(table: OwidTable): OwidTable { table = table.filterByEntityNames( this.selectionArray.selectedEntityNames @@ -498,7 +273,7 @@ export class LineChart // be sure all lines are un-dimmed if the cursor is above the graph itself if (this.dualAxis.innerBounds.contains(mouse)) { - this.hoveredSeriesName = undefined + this.hoverArray.clear() } this.tooltipState.target = hoverX === undefined ? null : { x: hoverX } @@ -547,10 +322,7 @@ export class LineChart } seriesIsBlurred(series: LineChartSeries): boolean { - return ( - this.isFocusModeActive && - !this.focusedSeriesNames.includes(series.seriesName) - ) + return this.hoverArray.isInBackground(series.seriesName) } @computed get activeX(): number | undefined { @@ -747,19 +519,18 @@ export class LineChart defaultRightPadding = 1 - @observable hoveredSeriesName?: SeriesName @observable private hoverTimer?: NodeJS.Timeout @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { clearTimeout(this.hoverTimer) - this.hoveredSeriesName = seriesName + this.hoverArray.clearAllAndActivate(seriesName) } @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.hoveredSeriesName = undefined + this.hoverArray.clear() }, 200) } @@ -767,24 +538,8 @@ export class LineChart this.clearHighlightedSeries() } - @computed get focusedSeriesNames(): string[] { - const { externalLegendHoverBin } = this.manager - const focusedSeriesNames = excludeUndefined([ - this.props.manager.entityYearHighlight?.entityName, - this.hoveredSeriesName, - ]) - if (externalLegendHoverBin) { - focusedSeriesNames.push( - ...this.series - .map((s) => s.seriesName) - .filter((name) => externalLegendHoverBin.contains(name)) - ) - } - return focusedSeriesNames - } - - @computed get isFocusModeActive(): boolean { - return this.focusedSeriesNames.length > 0 + @computed private get hasEntityYearHighlight(): boolean { + return this.props.manager.entityYearHighlight !== undefined } @action.bound onDocumentClick(e: MouseEvent): void { @@ -959,17 +714,15 @@ export class LineChart fontSize={this.fontSize} fontWeight={this.fontWeight} isStatic={this.isStatic} - focusedSeriesNames={this.focusedSeriesNames} onMouseOver={this.onLineLegendMouseOver} onMouseLeave={this.onLineLegendMouseLeave} /> )} {this.renderChartElements()} - {this.isTooltipActive && this.activeXVerticalLine} + {(this.isTooltipActive || this.hasEntityYearHighlight) && + this.activeXVerticalLine} {this.tooltip} ) @@ -1322,6 +1076,25 @@ export class LineChart }) } + @computed get renderSeries(): RenderLineChartSeries[] { + const { hoverArray } = this + + const series: RenderLineChartSeries[] = this.placedSeries.map( + (series) => { + return { + ...series, + hover: hoverArray.state(series.seriesName), + } + } + ) + + if (this.hoverArray.hasActiveSeries) { + return sortBy(series, byInteractionState) + } + + return series + } + // Order of the legend items on a line chart should visually correspond // to the order of the lines as the approach the legend @computed get lineLegendSeries(): LineLabelSeries[] { @@ -1344,6 +1117,7 @@ export class LineChart seriesName ), yValue: lastValue, + hover: this.hoverArray.state(series.seriesName), } }) } @@ -1427,6 +1201,18 @@ export class LineChart return this.dualAxis.horizontalAxis } + @action.bound onExternalLegendMouseOver(bin: ColorScaleBin): void { + this.hoverArray.clearAllAndActivate( + ...this.series + .map(({ seriesName }) => seriesName) + .filter((seriesName) => bin.contains(seriesName)) + ) + } + + @action.bound onExternalLegendMouseLeave(): void { + this.hoverArray.clear() + } + @computed get externalLegend(): HorizontalColorLegendManager | undefined { if (!this.manager.showLegend) { const numericLegendData = this.hasColorScale @@ -1453,8 +1239,210 @@ export class LineChart numericBinStrokeWidth: this.numericBinStrokeWidth, numericLegendData, categoricalLegendData, + onLegendMouseOver: this.onExternalLegendMouseOver, + onLegendMouseLeave: this.onExternalLegendMouseLeave, } } return undefined } } + +@observer +class Lines extends React.Component { + @computed get bounds(): Bounds { + const { horizontalAxis, verticalAxis } = this.props.dualAxis + return Bounds.fromCorners( + new PointVector(horizontalAxis.range[0], verticalAxis.range[0]), + new PointVector(horizontalAxis.range[1], verticalAxis.range[1]) + ) + } + + @computed private get markerRadius(): number { + return this.props.markerRadius ?? DEFAULT_MARKER_RADIUS + } + + @computed private get strokeWidth(): number { + return this.props.lineStrokeWidth ?? DEFAULT_STROKE_WIDTH + } + + @computed private get lineOutlineWidth(): number { + return this.props.lineOutlineWidth ?? DEFAULT_LINE_OUTLINE_WIDTH + } + + // Don't display point markers if there are very many of them for performance reasons + // Note that we're using circle elements instead of marker-mid because marker performance in Safari 10 is very poor for some reason + @computed private get hasMarkers(): boolean { + if (this.props.hidePoints) return false + const totalPoints = sum( + this.props.series + .filter((s) => s.hover !== InteractionState.background) + .map((series) => series.placedPoints.length) + ) + return totalPoints < 500 + } + + private renderPathForSeries( + series: PlacedLineChartSeries, + props: Partial> + ): React.ReactElement { + const strokeDasharray = series.isProjection ? "2,3" : undefined + return ( + [value.x, value.y]) as [ + number, + number, + ][] + )} + /> + ) + } + + private seriesHasMarkers(series: RenderLineChartSeries): boolean { + // Don't show markers for lines in the background + if (series.hover === InteractionState.background) return false + + // If the series only contains one point, then we will always want to + // show a marker/circle because we can't draw a line. + return ( + (this.hasMarkers || series.placedPoints.length === 1) && + !series.isProjection + ) + } + + renderLine(series: RenderLineChartSeries): React.ReactElement { + const color = series.placedPoints[0]?.color ?? DEFAULT_LINE_COLOR + const strokeDasharray = series.isProjection ? "2,3" : undefined + + const nonHovered = series.hover === InteractionState.background + const strokeWidth = nonHovered ? 1 : this.strokeWidth + const strokeOpacity = nonHovered ? 0.3 : 1 + + const outline = this.renderPathForSeries(series, { + id: makeIdForHumanConsumption("outline", series.seriesName), + stroke: this.props.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT, + strokeWidth: strokeWidth + this.lineOutlineWidth * 2, + }) + + if (this.props.multiColor) { + return ( + <> + {outline} + + + ) + } + + return ( + <> + {outline} + {this.renderPathForSeries(series, { + id: makeIdForHumanConsumption("line", series.seriesName), + stroke: color, + strokeWidth, + strokeOpacity, + })} + + ) + } + + renderLineMarkers( + series: RenderLineChartSeries + ): React.ReactElement | void { + if (!this.seriesHasMarkers(series)) return + + const { horizontalAxis } = this.props.dualAxis + const nonHovered = series.hover === InteractionState.background + const opacity = nonHovered ? 0.3 : 1 + + return ( + + {series.placedPoints.map((value, index) => ( + + ))} + + ) + } + + renderLineWithMarkers(series: RenderLineChartSeries): React.ReactElement { + return ( + <> + {this.renderLine(series)} + {this.renderLineMarkers(series)} + + ) + } + + renderLines(): React.ReactElement { + return ( + <> + {this.props.series.map((series) => { + const showMarkers = this.seriesHasMarkers(series) + return ( + + {this.renderLine(series)} + {showMarkers && this.renderLineMarkers(series)} + + ) + })} + + ) + } + + renderStatic(): React.ReactElement { + return this.renderLines() + } + + renderInteractive(): React.ReactElement { + const { bounds } = this + return ( + + + {this.renderLines()} + + ) + } + + render(): React.ReactElement { + return this.props.isStatic + ? this.renderStatic() + : this.renderInteractive() + } +} diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts index fe5d5a258bb..3349c6d78d2 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts @@ -1,9 +1,9 @@ import { DualAxis } from "../axis/Axis" import { ChartManager } from "../chart/ChartManager" import { - SeriesName, CoreValueType, EntityYearHighlight, + InteractionState, } from "@ourworldindata/types" import { ChartSeries } from "../chart/ChartInterface" import { Color } from "@ourworldindata/utils" @@ -30,10 +30,13 @@ export interface PlacedLineChartSeries extends LineChartSeries { placedPoints: PlacedPoint[] } +export interface RenderLineChartSeries extends PlacedLineChartSeries { + hover?: InteractionState +} + export interface LinesProps { dualAxis: DualAxis - placedSeries: PlacedLineChartSeries[] - focusedSeriesNames: SeriesName[] + series: RenderLineChartSeries[] hidePoints?: boolean lineStrokeWidth?: number lineOutlineWidth?: number diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx index 545f319cd3f..56164d348a8 100755 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.test.tsx @@ -21,7 +21,6 @@ const props: LineLegendProps = { }, ], x: 200, - focusedSeriesNames: [], yAxis: new AxisConfig({ min: 0, max: 100 }).toVerticalAxis(), } diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 26a160a02d1..622abe37725 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -21,6 +21,7 @@ import { VerticalAxis } from "../axis/Axis" import { Color, EntityName, + InteractionState, SeriesName, VerticalAlign, } from "@ourworldindata/types" @@ -51,6 +52,7 @@ export interface LineLabelSeries extends ChartSeries { formattedValue?: string placeFormattedValueInNewLine?: boolean yRange?: [number, number] + hover?: InteractionState } interface SizedSeries extends LineLabelSeries { @@ -69,14 +71,6 @@ interface PlacedSeries extends SizedSeries { midY: number } -function getSeriesKey( - series: PlacedSeries, - index: number, - key: string -): string { - return `${key}-${index}-` + series.seriesName -} - function groupBounds(group: PlacedSeries[]): Bounds { const first = group[0] const last = group[group.length - 1] @@ -101,21 +95,15 @@ function stackGroupVertically( @observer class LineLabels extends React.Component<{ series: PlacedSeries[] - uniqueKey: string needsConnectorLines: boolean showTextOutline?: boolean textOutlineColor?: Color anchor?: "start" | "end" - isFocus?: boolean isStatic?: boolean onClick?: (series: PlacedSeries) => void onMouseOver?: (series: PlacedSeries) => void onMouseLeave?: (series: PlacedSeries) => void }> { - @computed private get textOpacity(): number { - return this.props.isFocus ? 1 : 0.6 - } - @computed private get anchor(): "start" | "end" { return this.props.anchor ?? "start" } @@ -128,6 +116,10 @@ class LineLabels extends React.Component<{ return this.props.textOutlineColor ?? GRAPHER_BACKGROUND_DEFAULT } + private textOpacityForSeries(series: PlacedSeries): number { + return series.hover === InteractionState.background ? 0.6 : 1 + } + @computed private get markers(): { series: PlacedSeries labelText: { x: number; y: number } @@ -160,24 +152,19 @@ class LineLabels extends React.Component<{ @computed private get textLabels(): React.ReactElement { return ( - {this.markers.map(({ series, labelText }, index) => { - const key = getSeriesKey( - series, - index, - this.props.uniqueKey - ) + {this.markers.map(({ series, labelText }) => { const textColor = darkenColorForText(series.color) return ( {series.textWrap.render(labelText.x, labelText.y, { textProps: { fill: textColor, - opacity: this.textOpacity, + opacity: this.textOpacityForSeries(series), textAnchor: this.anchor, }, })} @@ -195,17 +182,12 @@ class LineLabels extends React.Component<{ if (!markersWithAnnotations) return return ( - {markersWithAnnotations.map(({ series, labelText }, index) => { - const key = getSeriesKey( - series, - index, - this.props.uniqueKey - ) + {markersWithAnnotations.map(({ series, labelText }) => { if (!series.annotationTextWrap) return return ( @@ -217,7 +199,8 @@ class LineLabels extends React.Component<{ { textProps: { fill: "#333", - opacity: this.textOpacity, + opacity: + this.textOpacityForSeries(series), textAnchor: this.anchor, style: { fontWeight: 300 }, }, @@ -234,8 +217,7 @@ class LineLabels extends React.Component<{ if (!this.props.needsConnectorLines) return return ( - {this.markers.map(({ series, connectorLine }, index) => { - const { isFocus } = this.props + {this.markers.map(({ series, connectorLine }) => { const { x1, x2 } = connectorLine const { level, @@ -247,16 +229,15 @@ class LineLabels extends React.Component<{ const step = (x2 - x1) / (totalLevels + 1) const markerXMid = x1 + step + level * step const d = `M${x1},${leftCenterY} H${markerXMid} V${rightCenterY} H${x2}` - const lineColor = isFocus ? "#999" : "#eee" + const lineColor = + series.hover === InteractionState.background + ? "#eee" + : "#999" return ( this.props.onMouseOver?.(series)} onMouseLeave={() => this.props.onMouseLeave?.(series) @@ -339,7 +316,6 @@ export interface LineLegendProps { // interactions isStatic?: boolean // don't add interactions if true - focusedSeriesNames?: SeriesName[] // currently in focus onClick?: (key: SeriesName) => void onMouseOver?: (key: SeriesName) => void onMouseLeave?: () => void @@ -471,16 +447,6 @@ export class LineLegend extends React.Component { return this.props.onClick ?? noop } - @computed get focusedSeriesNames(): EntityName[] { - return this.props.focusedSeriesNames ?? [] - } - - @computed get isFocusMode(): boolean { - return this.sizedLabels.some((label) => - this.focusedSeriesNames.includes(label.seriesName) - ) - } - @computed get legendX(): number { return this.props.x ?? 0 } @@ -776,20 +742,14 @@ export class LineLegend extends React.Component { } @computed private get backgroundSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this - const { isFocusMode } = this return this.placedSeries.filter( - (mark) => - isFocusMode && !focusedSeriesNames.includes(mark.seriesName) + (series) => series.hover === InteractionState.background ) } @computed private get focusedSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this - const { isFocusMode } = this return this.placedSeries.filter( - (mark) => - !isFocusMode || focusedSeriesNames.includes(mark.seriesName) + (series) => series.hover !== InteractionState.background ) } @@ -802,35 +762,13 @@ export class LineLegend extends React.Component { return max(this.placedSeries.map((series) => series.totalLevels)) ?? 0 } - private renderBackground(): React.ReactElement { - return ( - - this.onMouseOver(series.seriesName) - } - onClick={(series): void => this.onClick(series.seriesName)} - /> - ) - } - - // All labels are focused by default, moved to background when mouseover of other label - private renderFocus(): React.ReactElement { + private renderLineLabels(series: PlacedSeries[]): React.ReactElement { return ( @@ -850,8 +788,8 @@ export class LineLegend extends React.Component { id={makeIdForHumanConsumption("line-labels")} className="LineLabels" > - {this.renderBackground()} - {this.renderFocus()} + {this.renderLineLabels(this.backgroundSeries)} + {this.renderLineLabels(this.focusedSeries)} ) } diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 5906adeb482..f93d95f715b 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -111,6 +111,7 @@ import { makeTooltipRoundingNotice, } from "../tooltip/Tooltip" import { NoDataSection } from "./NoDataSection" +import { InteractionArray } from "../selection/InteractionArray" function computeSizeDomain(table: OwidTable, slug: ColumnSlug): ValueRange { const sizeValues = table.get(slug).values.filter(isNumber) @@ -130,8 +131,8 @@ export class ScatterPlotChart VerticalColorLegendManager, ColorScaleManager { - // currently hovered legend color - @observable private hoverColor?: Color + private hoverArray = new InteractionArray() + // current hovered individual series + tooltip position @observable tooltipState = new TooltipState<{ series: ScatterSeries @@ -409,12 +410,18 @@ export class ScatterPlotChart @action.bound onLegendMouseOver(color: string): void { if (isTouchDevice()) return - this.hoverColor = color + this.hoverArray.clearAllAndActivate( + ...uniq( + this.series + .filter((series) => series.color === color) + .map((series) => series.seriesName) + ) + ) } @action.bound onLegendMouseLeave(): void { if (isTouchDevice()) return - this.hoverColor = undefined + this.hoverArray.clear() } // When the color legend is clicked, toggle selection fo all associated keys @@ -454,22 +461,10 @@ export class ScatterPlotChart // All currently hovered series keys, combining the legend and the main UI @computed private get hoveredSeriesNames(): string[] { - const { hoverColor, tooltipState } = this - - const hoveredSeriesNames = - hoverColor === undefined - ? [] - : uniq( - this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - ) - - if (tooltipState.target) { - hoveredSeriesNames.push(tooltipState.target.series.seriesName) - } - - return hoveredSeriesNames + return excludeUndefined([ + ...this.hoverArray.activeSeriesNames, + this.tooltipState.target?.series.seriesName, + ]) } @computed private get focusedEntityNames(): string[] { diff --git a/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts b/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts new file mode 100644 index 00000000000..19b374203bd --- /dev/null +++ b/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts @@ -0,0 +1,99 @@ +import { SeriesName, InteractionState } from "@ourworldindata/types" +import { action, computed, observable } from "mobx" + +/** + * A class to manage a set of active series that are activated + * deactivated by being interacted with, e.g. by hovering or focusing. + */ +export class InteractionArray { + constructor() { + this.activeSeriesNames = [] + } + + @observable activeSeriesNames: SeriesName[] + + @computed get activeSeriesNameSet(): Set { + return new Set(this.activeSeriesNames) + } + + @computed get isEmpty(): boolean { + return this.activeSeriesNames.length === 0 + } + + @computed get hasActiveSeries(): boolean { + return !this.isEmpty + } + + @computed get first(): SeriesName | undefined { + if (this.hasActiveSeries) return this.activeSeriesNames[0] + return undefined + } + + /** + * Whether a series is currently interacted with + */ + isActive(seriesName: SeriesName): boolean { + return this.activeSeriesNameSet.has(seriesName) + } + + /** + * Whether a series is in the foreground, i.e. either + * the chart isn't currently interacted with (in which + * all series are in the foreground by default) or the + * series is currently active. + */ + isInForeground(seriesName: SeriesName): boolean { + return this.isEmpty || this.isActive(seriesName) + } + + /** + * Whether a series is in the background, i.e. the chart + * is currently interacted with but the series isn't. + */ + isInBackground(seriesName: SeriesName): boolean { + return this.hasActiveSeries && !this.isActive(seriesName) + } + + /** + * Get the interaction state of a series. Either 'active' or + * 'background' (see definitions above). Returns undefined if + * the chart isn't currently interacted with. + */ + state(seriesName: SeriesName): InteractionState | undefined { + if (this.isEmpty) return undefined + return this.isInForeground(seriesName) + ? InteractionState.active + : InteractionState.background + } + + @action.bound private _activate(seriesName: SeriesName): this { + if (!this.activeSeriesNameSet.has(seriesName)) + this.activeSeriesNames.push(seriesName) + return this + } + + @action.bound activate(...seriesNames: SeriesName[]): this { + for (const seriesName of seriesNames) { + this._activate(seriesName) + } + return this + } + + @action.bound deactivate(...seriesNames: SeriesName[]): this { + const seriesNameSet = new Set(seriesNames) + this.activeSeriesNames = this.activeSeriesNames.filter( + (name) => !seriesNameSet.has(name) + ) + return this + } + + @action.bound clearAllAndActivate(...seriesNames: SeriesName[]): this { + this.clear() + this.activate(...seriesNames) + return this + } + + @action.bound clear(): void { + this.activeSeriesNames = [] + } +} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 50077545ea1..667e33fe11b 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -9,11 +9,11 @@ import { makeIdForHumanConsumption, guid, excludeUndefined, - partition, getRelativeMouse, minBy, dyFromAlign, uniq, + sortBy, } from "@ourworldindata/utils" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" @@ -33,9 +33,9 @@ import { Time, SeriesStrategy, EntityName, - RenderMode, VerticalAlign, FacetStrategy, + InteractionState, } from "@ourworldindata/types" import { ChartInterface } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" @@ -44,12 +44,14 @@ import { select } from "d3-selection" import { PlacedSlopeChartSeries, RawSlopeChartSeries, + RenderSlopeChartSeries, SlopeChartSeries, } from "./SlopeChartConstants" import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + byInteractionState, getDefaultFailMessage, getShortNameForEntity, makeSelectionArray, @@ -84,7 +86,8 @@ import { import { SelectionArray } from "../selection/SelectionArray" import { Halo } from "../halo/Halo" import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" -import { CategoricalBin } from "../color/ColorScaleBin" +import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" +import { InteractionArray } from "../selection/InteractionArray" type SVGMouseOrTouchEvent = | React.MouseEvent @@ -110,10 +113,10 @@ export class SlopeChart { private slopeAreaRef: React.RefObject = React.createRef() private defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines + private hoverArray = new InteractionArray() private sidebarMargin = 10 - @observable hoveredSeriesName?: string @observable tooltipState = new TooltipState<{ series: SlopeChartSeries }>({ fade: "immediate" }) @@ -232,10 +235,6 @@ export class SlopeChart return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } - @computed private get isFocusModeActive(): boolean { - return this.focusedSeriesNames.length > 0 - } - @computed private get yColumns(): CoreColumn[] { return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) } @@ -443,6 +442,25 @@ export class SlopeChart }) } + @computed private get renderSeries(): RenderSlopeChartSeries[] { + const { hoverArray } = this + + const series: RenderSlopeChartSeries[] = this.placedSeries.map( + (series) => { + return { + ...series, + hover: hoverArray.state(series.seriesName), + } + } + ) + + if (this.hoverArray.hasActiveSeries) { + return sortBy(series, byInteractionState) + } + + return series + } + @computed private get noDataSeries(): RawSlopeChartSeries[] { return this.rawSeries.filter((series) => !this.isSeriesValid(series)) @@ -536,6 +554,18 @@ export class SlopeChart : 0 } + @action.bound onExternalLegendMouseOver(bin: ColorScaleBin): void { + this.hoverArray.clearAllAndActivate( + ...this.series + .map(({ seriesName }) => seriesName) + .filter((seriesName) => bin.contains(seriesName)) + ) + } + + @action.bound onExternalLegendMouseLeave(): void { + this.hoverArray.clear() + } + @computed get externalLegend(): HorizontalColorLegendManager | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series.map( @@ -547,7 +577,11 @@ export class SlopeChart color: series.color, }) ) - return { categoricalLegendData } + return { + categoricalLegendData, + onLegendMouseOver: this.onExternalLegendMouseOver, + onLegendMouseLeave: this.onExternalLegendMouseLeave, + } } return undefined } @@ -583,7 +617,6 @@ export class SlopeChart verticalAlign: VerticalAlign.top, showTextOutlines: true, textOutlineColor: this.backgroundColor, - focusedSeriesNames: this.focusedSeriesNames, onMouseOver: this.onLineLegendMouseOver, onMouseLeave: this.onLineLegendMouseLeave, } @@ -748,27 +781,6 @@ export class SlopeChart return !!this.manager.isSemiNarrow || this.isNarrow } - @computed get focusedSeriesNames(): SeriesName[] { - const focusedSeriesNames: SeriesName[] = [] - - // hovered series name - if (this.hoveredSeriesName) - focusedSeriesNames.push(this.hoveredSeriesName) - - // hovered legend item in the external facet legend - if (this.manager.externalLegendHoverBin) { - focusedSeriesNames.push( - ...this.series - .map((s) => s.seriesName) - .filter((name) => - this.manager.externalLegendHoverBin?.contains(name) - ) - ) - } - - return focusedSeriesNames - } - private constructSingleLineLegendSeries( series: SlopeChartSeries, getValue: (series: SlopeChartSeries) => number, @@ -791,6 +803,7 @@ export class SlopeChart formattedValue: showSeriesName ? formattedValue : undefined, placeFormattedValueInNewLine: this.useCompactLayout, yValue: value, + hover: this.hoverArray.state(seriesName), } } @@ -884,24 +897,24 @@ export class SlopeChart private hoverTimer?: NodeJS.Timeout @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { clearTimeout(this.hoverTimer) - this.hoveredSeriesName = seriesName + this.hoverArray.clearAllAndActivate(seriesName) } @action.bound onLineLegendMouseLeave(): void { clearTimeout(this.hoverTimer) this.hoverTimer = setTimeout(() => { // wait before clearing selection in case the mouse is moving quickly over neighboring labels - this.hoveredSeriesName = undefined + this.hoverArray.clear() }, 200) } @action.bound onSlopeMouseOver(series: SlopeChartSeries): void { - this.hoveredSeriesName = series.seriesName + this.hoverArray.clearAllAndActivate(series.seriesName) this.tooltipState.target = { series } } @action.bound onSlopeMouseLeave(): void { - this.hoveredSeriesName = undefined + this.hoverArray.clear() this.tooltipState.target = null } @@ -1066,48 +1079,18 @@ export class SlopeChart ) } - private renderSlope( - series: PlacedSlopeChartSeries, - mode?: RenderMode - ): React.ReactElement { - return ( - - ) - } - private renderSlopes() { - if (!this.isFocusModeActive) { - return this.placedSeries.map((series) => ( - - {this.renderSlope(series)} - - )) - } - - const [focusedSeries, backgroundSeries] = partition( - this.placedSeries, - (series) => this.focusedSeriesNames.includes(series.seriesName) - ) - return ( <> - {backgroundSeries.map((series) => ( - - {this.renderSlope(series, RenderMode.mute)} - - ))} - {focusedSeries.map((series) => ( - - {this.renderSlope(series, RenderMode.focus)} - + {this.renderSeries.map((series) => ( + ))} ) @@ -1264,9 +1247,8 @@ export class SlopeChart } interface SlopeProps { - series: PlacedSlopeChartSeries + series: RenderSlopeChartSeries color: string - mode?: RenderMode dotRadius?: number strokeWidth?: number outlineWidth?: number @@ -1278,7 +1260,6 @@ interface SlopeProps { function Slope({ series, color, - mode = RenderMode.default, dotRadius = 2.5, strokeWidth = 2, outlineWidth = 0.5, @@ -1288,14 +1269,9 @@ function Slope({ }: SlopeProps) { const { seriesName, startPoint, endPoint } = series - const showOutline = mode === RenderMode.default || mode === RenderMode.focus - - const opacity = { - [RenderMode.default]: 1, - [RenderMode.focus]: 1, - [RenderMode.mute]: 0.3, - [RenderMode.background]: 0.3, - }[mode] + const isInBackground = series.hover === InteractionState.background + const showOutline = !isInBackground + const opacity = isInBackground ? 0.3 : 1 return ( implements ChartInterface, AxisManager { + protected hoverArray = new InteractionArray() + transformTable(table: OwidTable): OwidTable { table = table.filterByEntityNames( this.selectionArray.selectedEntityNames @@ -434,6 +437,18 @@ export class AbstractStackedChart return this.unstackedSeries } + @action.bound onExternalLegendMouseOver(bin: ColorScaleBin): void { + this.hoverArray.clearAllAndActivate( + ...this.series + .map(({ seriesName }) => seriesName) + .filter((seriesName) => bin.contains(seriesName)) + ) + } + + @action.bound onExternalLegendMouseLeave(): void { + this.hoverArray.clear() + } + @computed get externalLegend(): HorizontalColorLegendManager | undefined { if (!this.manager.showLegend) { const categoricalLegendData = this.series @@ -447,7 +462,11 @@ export class AbstractStackedChart }) ) .reverse() - return { categoricalLegendData } + return { + categoricalLegendData, + onLegendMouseOver: this.onExternalLegendMouseOver, + onLegendMouseLeave: this.onExternalLegendMouseLeave, + } } return undefined } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index e89b176b4da..b62e8d1ab50 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -88,6 +88,7 @@ import { LabelCandidateWithElement, MarimekkoBarProps, } from "./MarimekkoChartConstants" +import { InteractionArray } from "../selection/InteractionArray" const MARKER_MARGIN: number = 4 const MARKER_AREA_HEIGHT: number = 25 @@ -270,8 +271,7 @@ export class MarimekkoChart defaultNoDataColor = OwidNoDataGray labelAngleInDegrees = -45 // 0 is horizontal, -90 is vertical from bottom to top, ... - // currently hovered legend color - @observable focusColorBin?: ColorScaleBin + private hoverArray = new InteractionArray() // current tooltip target & position @observable tooltipState = new TooltipState<{ @@ -898,11 +898,17 @@ export class MarimekkoChart } @action.bound onLegendMouseOver(bin: ColorScaleBin): void { - this.focusColorBin = bin + this.hoverArray.clearAllAndActivate( + ...this.placedItems + .filter((series) => + bin.contains(series.entityColor?.colorDomainValue) + ) + .map(({ entityName }) => entityName) + ) } @action.bound onLegendMouseLeave(): void { - this.focusColorBin = undefined + this.hoverArray.clear() } @computed private get legend(): HorizontalCategoricalColorLegend { @@ -1108,7 +1114,6 @@ export class MarimekkoChart dualAxis, x0, y0, - focusColorBin, placedLabels, labelLines, placedItems, @@ -1198,8 +1203,7 @@ export class MarimekkoChart entityName === tooltipState.target?.entityName && !tooltipState.fading const isFaint = - (focusColorBin !== undefined && - !focusColorBin.contains(entityColor?.colorDomainValue)) || + this.hoverArray.isInBackground(entityName) || (hasSelection && !isSelected) // figure out what the minimum height in domain space has to be so diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index b06229817ce..ed5ba2eb03d 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -18,7 +18,7 @@ import { max, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" -import { RenderMode, SeriesName } from "@ourworldindata/types" +import { InteractionState, SeriesName } from "@ourworldindata/types" import { GRAPHER_AREA_OPACITY_DEFAULT, GRAPHER_AREA_OPACITY_MUTE, @@ -64,22 +64,22 @@ interface AreasProps extends React.SVGAttributes { const STACKED_AREA_CHART_CLASS_NAME = "StackedArea" -const AREA_OPACITY: Partial> = { +const AREA_OPACITY = { default: GRAPHER_AREA_OPACITY_DEFAULT, focus: GRAPHER_AREA_OPACITY_FOCUS, mute: GRAPHER_AREA_OPACITY_MUTE, -} +} as const -const BORDER_OPACITY: Partial> = { +const BORDER_OPACITY = { default: 0.7, focus: 1, mute: 0.3, -} +} as const -const BORDER_WIDTH: Partial> = { +const BORDER_WIDTH = { default: 0.5, - mute: 1.5, -} + focus: 1.5, +} as const @observer class Areas extends React.Component { @@ -294,6 +294,15 @@ export class StackedAreaChart extends AbstractStackedChart { }) } + private hoverStateForSeries( + series: StackedSeries + ): InteractionState | undefined { + if (!this.focusedSeriesName) return undefined + return this.focusedSeriesName === series.seriesName + ? InteractionState.active + : InteractionState.background + } + @computed get lineLegendSeries(): LineLabelSeries[] { const { midpoints } = this return this.series @@ -303,6 +312,7 @@ export class StackedAreaChart extends AbstractStackedChart { label: series.seriesName, yValue: midpoints[index], isAllZeros: series.isAllZeros, + hover: this.hoverStateForSeries(series), })) .filter((series) => !series.isAllZeros) .reverse() @@ -344,7 +354,6 @@ export class StackedAreaChart extends AbstractStackedChart { extend(this.tooltipState.target, { series: undefined }) } - @observable lineLegendHoveredSeriesName?: SeriesName @observable private hoverTimer?: NodeJS.Timeout @computed protected get paddingForLegendRight(): number { @@ -394,44 +403,26 @@ export class StackedAreaChart extends AbstractStackedChart { @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { clearTimeout(this.hoverTimer) - this.lineLegendHoveredSeriesName = seriesName + this.hoverArray.clearAllAndActivate(seriesName) } @action.bound onLineLegendMouseLeave(): void { clearTimeout(this.hoverTimer) this.hoverTimer = setTimeout(() => { // wait before clearing selection in case the mouse is moving quickly over neighboring labels - this.lineLegendHoveredSeriesName = undefined + this.hoverArray.clear() }, 200) } - @computed get facetLegendHoveredSeriesName(): SeriesName | undefined { - const { externalLegendHoverBin } = this.manager - if (!externalLegendHoverBin) return undefined - // stacked area charts can't plot the same entity or column multiple times, - // so we just find the first series that matches the hovered legend item - const hoveredSeries = this.rawSeries.find((series) => - externalLegendHoverBin.contains(series.seriesName) - ) - return hoveredSeries?.seriesName - } - @computed get focusedSeriesName(): SeriesName | undefined { return ( // if the chart area is hovered this.tooltipState.target?.series ?? - // if the line legend is hovered - this.lineLegendHoveredSeriesName ?? - // if the facet legend is hovered - this.facetLegendHoveredSeriesName + // if the line legend or facet legend is hovered + this.hoverArray.first ) } - // used by the line legend component - @computed get focusedSeriesNames(): string[] { - return this.focusedSeriesName ? [this.focusedSeriesName] : [] - } - @action.bound private onCursorMove( ev: React.MouseEvent | React.TouchEvent ): void { @@ -483,7 +474,7 @@ export class StackedAreaChart extends AbstractStackedChart { if (!this.manager.shouldPinTooltipToBottom) { this.dismissTooltip() } - this.lineLegendHoveredSeriesName = undefined + this.hoverArray.clear() } @computed private get activeXVerticalLine(): @@ -665,7 +656,6 @@ export class StackedAreaChart extends AbstractStackedChart { fontSize={this.fontSize} seriesSortedByImportance={this.seriesSortedByImportance} isStatic={this.isStatic} - focusedSeriesNames={this.focusedSeriesNames} onMouseOver={this.onLineLegendMouseOver} onMouseLeave={this.onLineLegendMouseLeave} /> diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 6dcbab4f96c..8882e3469c2 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -72,13 +72,6 @@ interface StackedBarSegmentProps extends React.SVGAttributes { onBarMouseLeave: () => void } -interface TickmarkPlacement { - time: number - text: string - bounds: Bounds - isHidden: boolean -} - const BAR_OPACITY = { DEFAULT: GRAPHER_AREA_OPACITY_DEFAULT, FOCUS: GRAPHER_AREA_OPACITY_FOCUS, @@ -152,10 +145,6 @@ export class StackedBarChart super(props) } - // currently hovered legend color - @observable hoverColor?: string - // currently hovered axis label - @observable hoveredTick?: TickmarkPlacement // current hovered individual bar @observable tooltipState = new TooltipState<{ bar: StackedPoint @@ -220,42 +209,18 @@ export class StackedBarChart return this.props.enableLinearInterpolation ?? false } - // All currently hovered group keys, combining the legend and the main UI - @computed get hoverKeys(): string[] { - const { hoverColor, manager } = this - const { externalLegendHoverBin } = manager - - const hoverKeys = - hoverColor === undefined - ? [] - : uniq( - this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - ) - if (externalLegendHoverBin) { - hoverKeys.push( - ...this.rawSeries - .map((g) => g.seriesName) - .filter((name) => externalLegendHoverBin.contains(name)) - ) - } - - return hoverKeys - } - + // used by HorizontalColorLegendManager @computed get activeColors(): string[] { - const { hoverKeys } = this - const activeKeys = hoverKeys.length > 0 ? hoverKeys : [] - - if (!activeKeys.length) - // No hover means they're all active by default + // No hover means they're all active by default + if (this.hoverArray.isEmpty) return uniq(this.series.map((g) => g.color)) return uniq( this.series - .filter((g) => activeKeys.indexOf(g.seriesName) !== -1) - .map((g) => g.color) + .filter(({ seriesName }) => + this.hoverArray.isActive(seriesName) + ) + .map((series) => series.color) ) } @@ -336,7 +301,6 @@ export class StackedBarChart const { tooltipState: { target, position, fading }, series, - hoveredTick, formatColumn, } = this @@ -344,8 +308,6 @@ export class StackedBarChart let hoverTime: number if (hoverBar !== undefined) { hoverTime = hoverBar.position - } else if (hoveredTick !== undefined) { - hoverTime = hoveredTick.time } else return const { unit, shortUnit } = formatColumn @@ -431,20 +393,19 @@ export class StackedBarChart // The component expects a string, // the component expects a ColorScaleBin. @action.bound onLegendMouseOver(binOrColor: string | ColorScaleBin): void { - this.hoverColor = + const hoveredColor = typeof binOrColor === "string" ? binOrColor : binOrColor.color + this.hoverArray.clearAllAndActivate( + ...uniq( + this.series + .filter((g) => g.color === hoveredColor) + .map((g) => g.seriesName) + ) + ) } @action.bound onLegendMouseLeave(): void { - this.hoverColor = undefined - } - - @action.bound onLabelMouseOver(tick: TickmarkPlacement): void { - this.hoveredTick = tick - } - - @action.bound onLabelMouseLeave(): void { - this.hoveredTick = undefined + this.hoverArray.clear() } @action.bound onBarMouseOver( @@ -509,13 +470,12 @@ export class StackedBarChart return ( <> {this.series.map((series, index) => { - const isLegendHovered = this.hoverKeys.includes( + const foreground = this.hoverArray.isInForeground( series.seriesName ) - const opacity = - isLegendHovered || this.hoverKeys.length === 0 - ? BAR_OPACITY.DEFAULT - : BAR_OPACITY.MUTE + const opacity = foreground + ? BAR_OPACITY.DEFAULT + : BAR_OPACITY.MUTE return ( = React.createRef() + private hoverArray = new InteractionArray() + private applyMissingDataStrategy(table: OwidTable): OwidTable { if (this.missingDataStrategy === MissingDataStrategy.hide) { // If MissingDataStrategy is explicitly set to hide, drop rows (= times) where one of @@ -195,7 +198,9 @@ export class StackedDiscreteBarChart return this.manager.sortConfig ?? {} } - @observable focusSeriesName?: SeriesName + @computed get focusSeriesName(): SeriesName | undefined { + return this.hoverArray.first + } @computed get inputTable(): OwidTable { return this.manager.table @@ -535,15 +540,17 @@ export class StackedDiscreteBarChart } @action.bound onLegendMouseOver(bin: ColorScaleBin): void { - this.focusSeriesName = first( + const hoveredSeriesName = first( this.series .map((s) => s.seriesName) .filter((name) => bin.contains(name)) ) + if (hoveredSeriesName) + this.hoverArray.clearAllAndActivate(hoveredSeriesName) } @action.bound onLegendMouseLeave(): void { - this.focusSeriesName = undefined + this.hoverArray.clear() } @computed private get legend(): HorizontalCategoricalColorLegend { diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 8306197da2f..9bee343bc95 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -95,6 +95,11 @@ export enum DimensionProperty { table = "table", } +export enum InteractionState { + active = "active", // series is actively hovered/focused + background = "background", // another series is actively hovered/focused +} + // see CoreTableConstants.ts export type ColumnSlug = string // a url friendly name for a column in a table. cannot have spaces @@ -208,13 +213,6 @@ export interface AnnotationFieldsInTitle { changeInPrefix?: boolean } -export enum RenderMode { - default = "default", - focus = "focus", // hovered or focused - mute = "mute", // not hovered - background = "background", // not focused -} - export interface Tickmark { value: number priority: number diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index 42c61ccf4ad..8b97489f217 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -113,7 +113,7 @@ export { GrapherWindowType, AxisMinMaxValueStr, GrapherTooltipAnchor, - RenderMode, + InteractionState, } from "./grapherTypes/GrapherTypes.js" export {