diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 249920b44f..ce9eda4780 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -7,6 +7,8 @@ import { GrapherChartType, GRAPHER_CHART_TYPES, GRAPHER_TAB_QUERY_PARAMS, + InteractionState, + SeriesName, } from "@ourworldindata/types" import { LineChartSeries } from "../lineCharts/LineChartConstants" import { SelectionArray } from "../selection/SelectionArray" @@ -17,6 +19,7 @@ import { GRAPHER_SETTINGS_CLASS, validChartTypeCombinations, } from "../core/GrapherConstants" +import { ChartSeries } from "./ChartInterface" export const autoDetectYColumnSlugs = (manager: ChartManager): string[] => { if (manager.yColumnSlugs && manager.yColumnSlugs.length) @@ -188,3 +191,21 @@ export function findValidChartTypeCombination( } return undefined } + +/** Useful for sorting series by their interaction state */ +export function byInteractionState(series: { + hover: InteractionState +}): number { + if (series.hover.background) return 1 + if (series.hover.active) return 3 + return 2 +} + +export function getInteractionStateForSeries( + series: ChartSeries, + activeSeriesNames: SeriesName[] +): InteractionState { + const active = activeSeriesNames.includes(series.seriesName) + const background = activeSeriesNames.length > 0 && !active + return { active, background } +} diff --git a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts index 1485a2658c..1601f36f65 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts @@ -32,11 +32,13 @@ export const GRAPHER_BACKGROUND_BEIGE = "#fbf9f3" export const GRAPHER_DARK_TEXT = "#5b5b5b" export const GRAPHER_LIGHT_TEXT = "#858585" +export const GRAPHER_OPACITY_MUTE = 0.3 + export const GRAPHER_AXIS_LINE_WIDTH_DEFAULT = 1 export const GRAPHER_AXIS_LINE_WIDTH_THICK = 2 export const GRAPHER_AREA_OPACITY_DEFAULT = 0.8 -export const GRAPHER_AREA_OPACITY_MUTE = 0.3 +export const GRAPHER_AREA_OPACITY_MUTE = GRAPHER_OPACITY_MUTE export const GRAPHER_AREA_OPACITY_FOCUS = 1 export const BASE_FONT_SIZE = 16 diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index d75109dcf9..b078021de4 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -53,12 +53,14 @@ import { ColorScaleConfigInterface, ColorSchemeName, VerticalAlign, + InteractionState, } from "@ourworldindata/types" import { GRAPHER_AXIS_LINE_WIDTH_THICK, GRAPHER_AXIS_LINE_WIDTH_DEFAULT, BASE_FONT_SIZE, GRAPHER_BACKGROUND_DEFAULT, + GRAPHER_OPACITY_MUTE, } from "../core/GrapherConstants" import { ColorSchemes } from "../color/ColorSchemes" import { AxisConfig, AxisManager } from "../axis/AxisConfig" @@ -70,6 +72,7 @@ import { LinePoint, PlacedLineChartSeries, PlacedPoint, + RenderLineChartSeries, } from "./LineChartConstants" import { OwidTable, @@ -79,7 +82,9 @@ import { import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + byInteractionState, getDefaultFailMessage, + getInteractionStateForSeries, getSeriesKey, isTargetOutsideElement, makeClipPath, @@ -123,237 +128,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<{ @@ -548,8 +322,8 @@ export class LineChart seriesIsBlurred(series: LineChartSeries): boolean { return ( - this.isFocusModeActive && - !this.focusedSeriesNames.includes(series.seriesName) + this.isHoverModeActive && + !this.hoveredSeriesNames.includes(series.seriesName) ) } @@ -767,7 +541,7 @@ export class LineChart this.clearHighlightedSeries() } - @computed get focusedSeriesNames(): string[] { + @computed get hoveredSeriesNames(): string[] { const { externalLegendHoverBin } = this.manager const focusedSeriesNames = excludeUndefined([ this.props.manager.entityYearHighlight?.entityName, @@ -783,8 +557,12 @@ export class LineChart return focusedSeriesNames } - @computed get isFocusModeActive(): boolean { - return this.focusedSeriesNames.length > 0 + @computed get isHoverModeActive(): boolean { + return this.hoveredSeriesNames.length > 0 + } + + @computed private get hasEntityYearHighlight(): boolean { + return this.props.manager.entityYearHighlight !== undefined } @action.bound onDocumentClick(e: MouseEvent): void { @@ -959,17 +737,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 +1099,27 @@ export class LineChart }) } + private hoverStateForSeries(series: LineChartSeries): InteractionState { + return getInteractionStateForSeries(series, this.hoveredSeriesNames) + } + + @computed get renderSeries(): RenderLineChartSeries[] { + const series: RenderLineChartSeries[] = this.placedSeries.map( + (series) => { + return { + ...series, + hover: this.hoverStateForSeries(series), + } + } + ) + + if (this.isHoverModeActive) { + 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 +1142,7 @@ export class LineChart seriesName ), yValue: lastValue, + hover: this.hoverStateForSeries(series), } }) } @@ -1458,3 +1257,199 @@ export class LineChart 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((series) => this.seriesHasMarkers(series)) + .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 { + return !series.hover.background + } + + renderLine(series: RenderLineChartSeries): React.ReactElement { + const { hover } = series + + const color = series.placedPoints[0]?.color ?? DEFAULT_LINE_COLOR + const strokeDasharray = series.isProjection ? "2,3" : undefined + + const strokeWidth = hover.background ? 1 : this.strokeWidth + const strokeOpacity = hover.background ? GRAPHER_OPACITY_MUTE : 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 the series only contains one point, then we will always want to + // show a marker/circle because we can't draw a line. + const forceMarkers = series.placedPoints.length === 1 + + // check if we should hide markers on the chart and series level + const hideMarkers = !this.hasMarkers || !this.seriesHasMarkers(series) + + if (hideMarkers && !forceMarkers) return + + const { horizontalAxis } = this.props.dualAxis + const opacity = series.hover.background ? GRAPHER_OPACITY_MUTE : 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) => ( + + {this.renderLine(series)} + {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 fe5d5a258b..bb934281df 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 545f319cd3..56164d348a 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 ff46f8e451..030189ea0e 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" @@ -50,6 +51,7 @@ export interface LineLabelSeries extends ChartSeries { formattedValue?: string placeFormattedValueInNewLine?: boolean yRange?: [number, number] + hover?: InteractionState } interface SizedSeries extends LineLabelSeries { @@ -68,14 +70,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] @@ -100,21 +94,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" } @@ -127,6 +115,10 @@ class LineLabels extends React.Component<{ return this.props.textOutlineColor ?? GRAPHER_BACKGROUND_DEFAULT } + private textOpacityForSeries(series: PlacedSeries): number { + return series.hover?.background ? 0.6 : 1 + } + @computed private get markers(): { series: PlacedSeries labelText: { x: number; y: number } @@ -159,21 +151,16 @@ 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, { showTextOutline: this.showTextOutline, textOutlineColor: this.textOutlineColor, textProps: { fill: textColor, - opacity: this.textOpacity, + opacity: this.textOpacityForSeries(series), textAnchor: this.anchor, }, })} @@ -191,17 +178,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 ( @@ -213,7 +195,8 @@ class LineLabels extends React.Component<{ { textProps: { fill: "#333", - opacity: this.textOpacity, + opacity: + this.textOpacityForSeries(series), textAnchor: this.anchor, style: { fontWeight: 300 }, }, @@ -230,8 +213,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, @@ -243,16 +225,12 @@ 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?.background ? "#eee" : "#999" return ( - {this.props.series.map((series, index) => { + {this.props.series.map((series) => { const x = this.anchor === "start" ? series.origBounds.x : series.origBounds.x - series.bounds.width return ( this.props.onMouseOver?.(series)} onMouseLeave={() => this.props.onMouseLeave?.(series) @@ -335,7 +309,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 @@ -470,16 +443,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 } @@ -774,24 +737,6 @@ export class LineLegend extends React.Component { return this.partialInitialSeries.map((series) => series.seriesName) } - @computed private get backgroundSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this - const { isFocusMode } = this - return this.placedSeries.filter( - (mark) => - isFocusMode && !focusedSeriesNames.includes(mark.seriesName) - ) - } - - @computed private get focusedSeries(): PlacedSeries[] { - const { focusedSeriesNames } = this - const { isFocusMode } = this - return this.placedSeries.filter( - (mark) => - !isFocusMode || focusedSeriesNames.includes(mark.seriesName) - ) - } - // Does this placement need line markers or is the position of the labels already clear? @computed private get needsLines(): boolean { return this.placedSeries.some((series) => series.totalLevels > 1) @@ -801,56 +746,27 @@ 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 { - return ( - - this.onMouseOver(series.seriesName) - } - onClick={(series): void => this.onClick(series.seriesName)} - onMouseLeave={(series): void => - this.onMouseLeave(series.seriesName) - } - /> - ) - } - render(): React.ReactElement { return ( - {this.renderBackground()} - {this.renderFocus()} + + this.onMouseOver(series.seriesName) + } + onClick={(series): void => this.onClick(series.seriesName)} + onMouseLeave={(series): void => + this.onMouseLeave(series.seriesName) + } + /> ) } diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 0044ce1837..cd2ba25c64 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" @@ -23,6 +23,7 @@ import { GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, GRAPHER_FONT_SCALE_12, + GRAPHER_OPACITY_MUTE, } from "../core/GrapherConstants" import { ScaleType, @@ -33,9 +34,9 @@ import { Time, SeriesStrategy, EntityName, - RenderMode, VerticalAlign, FacetStrategy, + InteractionState, } from "@ourworldindata/types" import { ChartInterface } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" @@ -44,13 +45,16 @@ import { select } from "d3-selection" import { PlacedSlopeChartSeries, RawSlopeChartSeries, + RenderSlopeChartSeries, SlopeChartSeries, } from "./SlopeChartConstants" import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + byInteractionState, getDefaultFailMessage, + getInteractionStateForSeries, getShortNameForEntity, makeSelectionArray, } from "../chart/ChartUtils" @@ -232,8 +236,8 @@ export class SlopeChart return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } - @computed private get isFocusModeActive(): boolean { - return this.focusedSeriesNames.length > 0 + @computed private get isHoverModeActive(): boolean { + return this.hoveredSeriesNames.length > 0 } @computed private get yColumns(): CoreColumn[] { @@ -443,6 +447,27 @@ export class SlopeChart }) } + private hoverStateForSeries(series: SlopeChartSeries): InteractionState { + return getInteractionStateForSeries(series, this.hoveredSeriesNames) + } + + @computed private get renderSeries(): RenderSlopeChartSeries[] { + const series: RenderSlopeChartSeries[] = this.placedSeries.map( + (series) => { + return { + ...series, + hover: this.hoverStateForSeries(series), + } + } + ) + + if (this.isHoverModeActive) { + return sortBy(series, byInteractionState) + } + + return series + } + @computed private get noDataSeries(): RawSlopeChartSeries[] { return this.rawSeries.filter((series) => !this.isSeriesValid(series)) @@ -584,7 +609,6 @@ export class SlopeChart verticalAlign: VerticalAlign.top, showTextOutlines: true, textOutlineColor: this.backgroundColor, - focusedSeriesNames: this.focusedSeriesNames, onMouseOver: this.onLineLegendMouseOver, onMouseLeave: this.onLineLegendMouseLeave, } @@ -742,16 +766,16 @@ export class SlopeChart return !!this.manager.isSemiNarrow || this.isNarrow } - @computed get focusedSeriesNames(): SeriesName[] { - const focusedSeriesNames: SeriesName[] = [] + @computed get hoveredSeriesNames(): SeriesName[] { + const hoveredSeriesNames: SeriesName[] = [] // hovered series name if (this.hoveredSeriesName) - focusedSeriesNames.push(this.hoveredSeriesName) + hoveredSeriesNames.push(this.hoveredSeriesName) // hovered legend item in the external facet legend if (this.manager.externalLegendHoverBin) { - focusedSeriesNames.push( + hoveredSeriesNames.push( ...this.series .map((s) => s.seriesName) .filter((name) => @@ -760,7 +784,7 @@ export class SlopeChart ) } - return focusedSeriesNames + return hoveredSeriesNames } private constructSingleLineLegendSeries( @@ -785,6 +809,7 @@ export class SlopeChart formattedValue: showSeriesName ? formattedValue : undefined, placeFormattedValueInNewLine: this.useCompactLayout, yValue: value, + hover: this.hoverStateForSeries(series), } } @@ -812,12 +837,20 @@ export class SlopeChart ) } + private animSelection?: d3.Selection< + d3.BaseType, + unknown, + SVGGElement | null, + unknown + > private playIntroAnimation() { // Nice little intro animation - select(this.slopeAreaRef.current) + this.animSelection = select(this.slopeAreaRef.current) .selectAll(".slope") .attr("stroke-dasharray", "100%") .attr("stroke-dashoffset", "100%") + + this.animSelection .transition() .duration(600) .attr("stroke-dashoffset", "0%") @@ -831,6 +864,10 @@ export class SlopeChart } } + componentWillUnmount(): void { + if (this.animSelection) this.animSelection.interrupt() + } + @computed private get showSeriesNamesInLineLegendLeft(): boolean { return this.lineLegendMaxLevelLeft >= 4 && !!this.manager.showLegend } @@ -1060,48 +1097,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) => ( + ))} ) @@ -1261,9 +1268,8 @@ export class SlopeChart } interface SlopeProps { - series: PlacedSlopeChartSeries + series: RenderSlopeChartSeries color: string - mode?: RenderMode dotRadius?: number strokeWidth?: number outlineWidth?: number @@ -1275,7 +1281,6 @@ interface SlopeProps { function Slope({ series, color, - mode = RenderMode.default, dotRadius = 2.5, strokeWidth = 2, outlineWidth = 0.5, @@ -1285,14 +1290,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.background + const showOutline = !isInBackground + const opacity = isInBackground ? GRAPHER_OPACITY_MUTE : 1 return ( { 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 +298,13 @@ export class StackedAreaChart extends AbstractStackedChart { }) } + private hoverStateForSeries( + series: StackedSeries + ): InteractionState { + const focusedSeriesNames = excludeUndefined([this.hoveredSeriesName]) + return getInteractionStateForSeries(series, focusedSeriesNames) + } + @computed get lineLegendSeries(): LineLabelSeries[] { const { midpoints } = this return this.series @@ -303,6 +314,7 @@ export class StackedAreaChart extends AbstractStackedChart { label: series.seriesName, yValue: midpoints[index], isAllZeros: series.isAllZeros, + hover: this.hoverStateForSeries(series), })) .filter((series) => !series.isAllZeros) .reverse() @@ -416,7 +428,7 @@ export class StackedAreaChart extends AbstractStackedChart { return hoveredSeries?.seriesName } - @computed get focusedSeriesName(): SeriesName | undefined { + @computed get hoveredSeriesName(): SeriesName | undefined { return ( // if the chart area is hovered this.tooltipState.target?.series ?? @@ -427,9 +439,8 @@ export class StackedAreaChart extends AbstractStackedChart { ) } - // used by the line legend component - @computed get focusedSeriesNames(): string[] { - return this.focusedSeriesName ? [this.focusedSeriesName] : [] + @computed get hoveredSeriesNames(): string[] { + return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] } @action.bound private onCursorMove( @@ -665,7 +676,6 @@ export class StackedAreaChart extends AbstractStackedChart { fontSize={this.fontSize} seriesSortedByImportance={this.seriesSortedByImportance} isStatic={this.isStatic} - focusedSeriesNames={this.focusedSeriesNames} onMouseOver={this.onLineLegendMouseOver} onMouseLeave={this.onLineLegendMouseLeave} /> @@ -680,7 +690,7 @@ export class StackedAreaChart extends AbstractStackedChart { ) @@ -718,7 +728,7 @@ export class StackedAreaChart extends AbstractStackedChart { diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 5a178142c2..c1b01085b3 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 interface InteractionState { + active: boolean // actively hovered or focused + background: boolean // another series is actively hovered or 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 49ede31ad5..6d7ba129dc 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -113,7 +113,7 @@ export { GrapherWindowType, AxisMinMaxValueStr, GrapherTooltipAnchor, - RenderMode, + type InteractionState, } from "./grapherTypes/GrapherTypes.js" export {