From 2c1bc837614abeea8476729d80b25754e67ac74e Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Tue, 17 Dec 2024 17:25:39 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(line+slope)=20add=20focus=20sta?= =?UTF-8?q?te=20/=20TAS-739=20(#4272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the option to highlight lines in line and slope charts ### Summary - Adds a new config field, `focusedSeriesNames` (suggestions for a different name welcome) - A line is identified by its series name which is either an entity name, a column display name or a combination of both - The list of focused series names is persisted to the URL as `focus` query param - The entity name utility functions are used to parse and serialise focused series names, so that the same delimiter is used and entity names are mapped to their codes if possible - This breaks if a column name contains `~` (the delimiter) which theoretically is possible but I don't think we need to worry about that now - Focused lines have bold labels, non-focused lines are grayed out - Grapher makes an effort to prevent the chart to enter a 'bad state' where all lines are grayed out because the focused line doesn't exist - This includes removing all elements from the focus array when the facet strategy changes and dismissing focused entities when they're unselected #### In the admin - There is a new 'Data to highlight' section below the entity selection section - If the chart is in a bad state because one of the focused series names is invalid, saving is disabled and shows an error message ### Follow up - It's a bit ugly that `selectedEntityNames` and `focusedSeriesNames` are always serialised, even for an empty Grapher. I've fixes that in a [follow-up PR](https://github.com/owid/owid-grapher/pull/4294) - The line legend method that drops labels if there are to many is a bit difficult to read. I'll open another PR with a refactor --- adminSiteClient/AbstractChartEditor.ts | 20 ++ adminSiteClient/ChartEditorTypes.ts | 11 +- adminSiteClient/ChartEditorView.tsx | 13 +- adminSiteClient/DimensionCard.tsx | 5 +- adminSiteClient/EditorBasicTab.tsx | 16 +- adminSiteClient/EditorDataTab.tsx | 162 +++++++++++- adminSiteClient/EditorExportTab.tsx | 1 + adminSiteClient/EditorFeatures.tsx | 7 + adminSiteClient/SaveButtons.tsx | 66 ++--- adminSiteClient/admin.scss | 15 ++ .../src/captionedChart/CaptionedChart.tsx | 12 +- .../grapher/src/chart/ChartManager.ts | 3 + .../grapher/src/chart/ChartUtils.tsx | 50 ++-- .../grapher/src/color/ColorConstants.ts | 5 +- .../grapher/src/core/EntityUrlBuilder.ts | 29 ++- .../grapher/src/core/Grapher.jsdom.test.ts | 9 +- .../grapher/src/core/Grapher.tsx | 92 +++++-- .../grapher/src/core/GrapherUrl.ts | 15 +- .../src/entitySelector/EntitySelector.tsx | 14 +- .../grapher/src/facetChart/FacetChart.tsx | 33 ++- .../grapher/src/focus/FocusArray.test.ts | 57 +++++ .../grapher/src/focus/FocusArray.ts | 95 +++++++ .../HorizontalColorLegends.tsx | 5 +- .../grapher/src/lineCharts/LineChart.tsx | 179 ++++++++----- .../src/lineCharts/LineChartConstants.ts | 1 + .../src/lineCharts/LineChartHelpers.ts | 4 + .../grapher/src/lineLegend/LineLegend.tsx | 238 +++++++++++++----- .../src/schema/grapher-schema.006.yaml | 8 + .../grapher/src/slopeCharts/SlopeChart.tsx | 117 +++++---- .../src/slopeCharts/SlopeChartConstants.ts | 2 + .../src/stackedCharts/StackedAreaChart.tsx | 8 +- .../types/src/dbTypes/ChartViews.ts | 3 + .../types/src/grapherTypes/GrapherTypes.ts | 4 + packages/@ourworldindata/utils/src/Util.ts | 13 + packages/@ourworldindata/utils/src/index.ts | 1 + 35 files changed, 1010 insertions(+), 303 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/focus/FocusArray.test.ts create mode 100644 packages/@ourworldindata/grapher/src/focus/FocusArray.ts diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts index 4db1c70e3d8..c22baa71c8a 100644 --- a/adminSiteClient/AbstractChartEditor.ts +++ b/adminSiteClient/AbstractChartEditor.ts @@ -5,6 +5,8 @@ import { diffGrapherConfigs, mergeGrapherConfigs, PostReference, + SeriesName, + difference, } from "@ourworldindata/utils" import { action, computed, observable, when } from "mobx" import { EditorFeatures } from "./EditorFeatures.js" @@ -163,6 +165,24 @@ export abstract class AbstractChartEditor< return Object.hasOwn(this.activeParentConfig, property) } + @computed get invalidFocusedSeriesNames(): SeriesName[] { + const { grapher } = this + + // if focusing is not supported, then all focused series are invalid + if (!this.features.canHighlightSeries) { + return grapher.focusArray.seriesNames + } + + // find invalid focused series + const availableSeriesNames = grapher.chartSeriesNames + const focusedSeriesNames = grapher.focusArray.seriesNames + return difference(focusedSeriesNames, availableSeriesNames) + } + + @action.bound removeInvalidFocusedSeriesNames(): void { + this.grapher.focusArray.remove(...this.invalidFocusedSeriesNames) + } + abstract get isNewGrapher(): boolean abstract get availableTabs(): EditorTab[] diff --git a/adminSiteClient/ChartEditorTypes.ts b/adminSiteClient/ChartEditorTypes.ts index d1d6f7ffd12..966741c5861 100644 --- a/adminSiteClient/ChartEditorTypes.ts +++ b/adminSiteClient/ChartEditorTypes.ts @@ -6,13 +6,8 @@ export type FieldWithDetailReferences = | "axisLabelX" | "axisLabelY" -export interface DimensionErrorMessage { - displayName?: string -} +type ErrorMessageFieldName = FieldWithDetailReferences | "focusedSeriesNames" -export type ErrorMessages = Partial> +export type ErrorMessages = Partial> -export type ErrorMessagesForDimensions = Record< - DimensionProperty, - DimensionErrorMessage[] -> +export type ErrorMessagesForDimensions = Record diff --git a/adminSiteClient/ChartEditorView.tsx b/adminSiteClient/ChartEditorView.tsx index 3a23203cf83..2cd3005e101 100644 --- a/adminSiteClient/ChartEditorView.tsx +++ b/adminSiteClient/ChartEditorView.tsx @@ -265,6 +265,14 @@ export class ChartEditorView< } ) + // add an error message if any focused series names are invalid + const { invalidFocusedSeriesNames = [] } = this.editor ?? {} + if (invalidFocusedSeriesNames.length > 0) { + const invalidNames = invalidFocusedSeriesNames.join(", ") + const message = `Invalid focus state. The following entities/indicators are not plotted: ${invalidNames}` + errorMessages.focusedSeriesNames = message + } + return errorMessages } @@ -287,9 +295,8 @@ export class ChartEditorView< // add error message if details are referenced in the display name if (hasDetailsInDisplayName) { - errorMessages[slot.property][dimensionIndex] = { - displayName: "Detail syntax is not supported", - } + errorMessages[slot.property][dimensionIndex] = + `Detail syntax is not supported for display names of indicators: ${dimension.display.name}` } }) }) diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index 74ff1cdad01..4053be981d3 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -4,7 +4,6 @@ import { observer } from "mobx-react" import { ChartDimension } from "@ourworldindata/grapher" import { OwidColumnDef, OwidVariableRoundingMode } from "@ourworldindata/types" import { startCase } from "@ourworldindata/utils" -import { DimensionErrorMessage } from "./ChartEditorTypes.js" import { Toggle, BindAutoString, @@ -35,7 +34,7 @@ export class DimensionCard< onChange: (dimension: ChartDimension) => void onEdit?: () => void onRemove?: () => void - errorMessage?: DimensionErrorMessage + errorMessage?: string }> { @observable.ref isExpanded: boolean = false @@ -171,7 +170,7 @@ export class DimensionCard< store={dimension.display} auto={column.displayName} onBlur={this.onChange} - errorMessage={this.props.errorMessage?.displayName} + errorMessage={this.props.errorMessage} /> this.grapher.validChartTypes, - this.updateDefaults + () => { + this.updateDefaultSelection() + this.editor.removeInvalidFocusedSeriesNames() + } ), reaction( () => this.grapher.yColumnsFromDimensions.length, - this.updateDefaults + () => { + this.updateDefaultSelection() + this.editor.removeInvalidFocusedSeriesNames() + } ) ) } diff --git a/adminSiteClient/EditorDataTab.tsx b/adminSiteClient/EditorDataTab.tsx index f36cd92fc3b..84c15f868b6 100644 --- a/adminSiteClient/EditorDataTab.tsx +++ b/adminSiteClient/EditorDataTab.tsx @@ -1,11 +1,18 @@ import React from "react" -import { moveArrayItemToIndex, omit } from "@ourworldindata/utils" +import { + differenceOfSets, + moveArrayItemToIndex, + omit, + sortBy, +} from "@ourworldindata/utils" import { computed, action, observable } from "mobx" import { observer } from "mobx-react" +import cx from "classnames" import { EntitySelectionMode, MissingDataStrategy, EntityName, + SeriesName, } from "@ourworldindata/types" import { Grapher } from "@ourworldindata/grapher" import { ColorBox, SelectField, Section, FieldsRow } from "./Forms.js" @@ -24,14 +31,20 @@ import { } from "react-beautiful-dnd" import { AbstractChartEditor } from "./AbstractChartEditor.js" -interface EntityItemProps extends React.HTMLProps { +interface EntityListItemProps extends React.HTMLProps { grapher: Grapher entityName: EntityName onRemove?: () => void } +interface SeriesListItemProps extends React.HTMLProps { + seriesName: SeriesName + isValid?: boolean + onRemove?: () => void +} + @observer -class EntityItem extends React.Component { +class EntityListItem extends React.Component { @observable.ref isChoosingColor: boolean = false @computed get table() { @@ -89,7 +102,36 @@ class EntityItem extends React.Component { } @observer -export class KeysSection extends React.Component<{ +class SeriesListItem extends React.Component { + @action.bound onRemove() { + this.props.onRemove?.() + } + + render() { + const { props } = this + const { seriesName, isValid } = props + const rest = omit(props, ["seriesName", "isValid", "onRemove"]) + + const className = cx("ListItem", "list-group-item", { + invalid: !isValid, + }) + const annotation = !isValid ? "(not plotted)" : "" + + return ( +
+
+ {seriesName} {annotation} +
+
+ +
+
+ ) + } +} + +@observer +export class EntitySelectionSection extends React.Component<{ editor: AbstractChartEditor }> { @observable.ref dragKey?: EntityName @@ -100,6 +142,12 @@ export class KeysSection extends React.Component<{ @action.bound onAddKey(entityName: EntityName) { this.editor.grapher.selection.selectEntity(entityName) + this.editor.removeInvalidFocusedSeriesNames() + } + + @action.bound onRemoveKey(entityName: EntityName) { + this.editor.grapher.selection.deselectEntity(entityName) + this.editor.removeInvalidFocusedSeriesNames() } @action.bound onDragEnd(result: DropResult) { @@ -122,6 +170,7 @@ export class KeysSection extends React.Component<{ grapher.selection.setSelectedEntities( activeParentConfig.selectedEntityNames ) + this.editor.removeInvalidFocusedSeriesNames() } render() { @@ -183,12 +232,12 @@ export class KeysSection extends React.Component<{ {...provided.draggableProps} {...provided.dragHandleProps} > - - selection.deselectEntity( + this.onRemoveKey( entityName ) } @@ -216,6 +265,102 @@ export class KeysSection extends React.Component<{ } } +@observer +export class FocusSection extends React.Component<{ + editor: AbstractChartEditor +}> { + @computed get editor() { + return this.props.editor + } + + @action.bound addToFocusedSeries(seriesName: SeriesName) { + this.editor.grapher.focusArray.add(seriesName) + } + + @action.bound removeFromFocusedSeries(seriesName: SeriesName) { + this.editor.grapher.focusArray.remove(seriesName) + } + + @action.bound setFocusedSeriesNamesToParentValue() { + const { grapher, activeParentConfig } = this.editor + if (!activeParentConfig || !activeParentConfig.focusedSeriesNames) + return + grapher.focusArray.clearAllAndAdd( + ...activeParentConfig.focusedSeriesNames + ) + this.editor.removeInvalidFocusedSeriesNames() + } + + render() { + const { editor } = this + const { grapher } = editor + + const isFocusInherited = + editor.isPropertyInherited("focusedSeriesNames") + + const focusedSeriesNameSet = grapher.focusArray.seriesNameSet + const focusedSeriesNames = grapher.focusArray.seriesNames + + // series available to highlight are those that are currently plotted + const seriesNameSet = new Set(grapher.chartSeriesNames) + const availableSeriesNameSet = differenceOfSets([ + seriesNameSet, + focusedSeriesNameSet, + ]) + + // focusing only makes sense for two or more plotted series + if (focusedSeriesNameSet.size === 0 && availableSeriesNameSet.size < 2) + return null + + const availableSeriesNames: SeriesName[] = sortBy( + Array.from(availableSeriesNameSet) + ) + + const invalidFocusedSeriesNames = differenceOfSets([ + focusedSeriesNameSet, + seriesNameSet, + ]) + + return ( +
+ + ({ value: key }))} + /> + {editor.couldPropertyBeInherited("focusedSeriesNames") && ( + + )} + + {focusedSeriesNames.map((seriesName) => ( + + this.removeFromFocusedSeries(seriesName) + } + /> + ))} +
+ ) + } +} + @observer class MissingDataSection< Editor extends AbstractChartEditor, @@ -331,7 +476,10 @@ export class EditorDataTab< - + + {features.canHighlightSeries && ( + + )} {features.canSpecifyMissingDataStrategy && ( )} diff --git a/adminSiteClient/EditorExportTab.tsx b/adminSiteClient/EditorExportTab.tsx index dc2d47a8cb6..84fa7bac40a 100644 --- a/adminSiteClient/EditorExportTab.tsx +++ b/adminSiteClient/EditorExportTab.tsx @@ -211,6 +211,7 @@ export class EditorExportTab< staticFormat: format, selectedEntityNames: this.grapher.selection.selectedEntityNames, + focusedSeriesNames: this.grapher.focusedSeriesNames, isSocialMediaExport, }) } diff --git a/adminSiteClient/EditorFeatures.tsx b/adminSiteClient/EditorFeatures.tsx index 0fefaa9c693..243f0a84ffb 100644 --- a/adminSiteClient/EditorFeatures.tsx +++ b/adminSiteClient/EditorFeatures.tsx @@ -156,4 +156,11 @@ export class EditorFeatures { ) ) } + + @computed get canHighlightSeries() { + return ( + (this.grapher.hasLineChart || this.grapher.hasSlopeChart) && + this.grapher.isOnChartTab + ) + } } diff --git a/adminSiteClient/SaveButtons.tsx b/adminSiteClient/SaveButtons.tsx index fe43cbd95f9..dbb9ed71714 100644 --- a/adminSiteClient/SaveButtons.tsx +++ b/adminSiteClient/SaveButtons.tsx @@ -2,7 +2,7 @@ import React from "react" import { ChartEditor, isChartEditorInstance } from "./ChartEditor.js" import { action, computed } from "mobx" import { observer } from "mobx-react" -import { isEmpty, omit } from "@ourworldindata/utils" +import { excludeUndefined, omit } from "@ourworldindata/utils" import { IndicatorChartEditor, isIndicatorChartEditorInstance, @@ -73,22 +73,20 @@ class SaveButtonsForChart extends React.Component<{ else this.props.editor.publishGrapher() } - @computed get hasEditingErrors(): boolean { + @computed get editingErrors(): string[] { const { errorMessages, errorMessagesForDimensions } = this.props - - if (!isEmpty(errorMessages)) return true - - const allErrorMessagesForDimensions = Object.values( - errorMessagesForDimensions - ).flat() - return allErrorMessagesForDimensions.some((error) => error) + return excludeUndefined([ + ...Object.values(errorMessages), + ...Object.values(errorMessagesForDimensions).flat(), + ]) } render() { - const { hasEditingErrors } = this + const { editingErrors } = this const { editor } = this.props const { grapher } = editor + const hasEditingErrors = editingErrors.length > 0 const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors return ( @@ -125,11 +123,17 @@ class SaveButtonsForChart extends React.Component<{ )} + {editingErrors.map((error, i) => ( +
+ {error} +
+ ))} ) } @@ -145,23 +149,21 @@ class SaveButtonsForIndicatorChart extends React.Component<{ void this.props.editor.saveGrapher() } - @computed get hasEditingErrors(): boolean { + @computed get editingErrors(): string[] { const { errorMessages, errorMessagesForDimensions } = this.props - - if (!isEmpty(errorMessages)) return true - - const allErrorMessagesForDimensions = Object.values( - errorMessagesForDimensions - ).flat() - return allErrorMessagesForDimensions.some((error) => error) + return excludeUndefined([ + ...Object.values(errorMessages), + ...Object.values(errorMessagesForDimensions).flat(), + ]) } render() { - const { hasEditingErrors } = this + const { editingErrors } = this const { editor } = this.props const { grapher } = editor const isTrivial = editor.isNewGrapher && !editor.isModified + const hasEditingErrors = editingErrors.length > 0 const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors || isTrivial @@ -176,6 +178,11 @@ class SaveButtonsForIndicatorChart extends React.Component<{ ? "Create indicator chart" : "Update indicator chart"} + {editingErrors.map((error, i) => ( +
+ {error} +
+ ))} ) } @@ -191,22 +198,20 @@ class SaveButtonsForChartView extends React.Component<{ void this.props.editor.saveGrapher() } - @computed get hasEditingErrors(): boolean { + @computed get editingErrors(): string[] { const { errorMessages, errorMessagesForDimensions } = this.props - - if (!isEmpty(errorMessages)) return true - - const allErrorMessagesForDimensions = Object.values( - errorMessagesForDimensions - ).flat() - return allErrorMessagesForDimensions.some((error) => error) + return excludeUndefined([ + ...Object.values(errorMessages), + ...Object.values(errorMessagesForDimensions).flat(), + ]) } render() { - const { hasEditingErrors } = this + const { editingErrors } = this const { editor } = this.props const { grapher } = editor + const hasEditingErrors = editingErrors.length > 0 const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors return ( @@ -226,6 +231,11 @@ class SaveButtonsForChartView extends React.Component<{ > Go to parent chart + {editingErrors.map((error, i) => ( +
+ {error} +
+ ))} ) } diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index cb3c4f11e0e..966b05e0a57 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -509,6 +509,21 @@ $nav-height: 45px; } } +.ListItem { + display: flex; + justify-content: space-between; + align-items: center; + + > div { + display: flex; + align-items: center; + } + + &.invalid { + background: #fce9e6; + } +} + .EditableListItem { @extend .draggable; display: flex; diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index df10e98a56a..babbce9753e 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -34,7 +34,6 @@ import { SelectionArray } from "../selection/SelectionArray" import { EntityName, GRAPHER_CHART_TYPES, - FacetStrategy, RelatedQuestionsConfig, Color, GrapherTabName, @@ -83,6 +82,7 @@ export interface CaptionedChartManager isOnMapTab?: boolean isOnTableTab?: boolean activeChartType?: GrapherChartType + isFaceted?: boolean isLineChartThatTurnedIntoDiscreteBarActive?: boolean showEntitySelectionToggle?: boolean isExportingForSocialMedia?: boolean @@ -185,13 +185,6 @@ export class CaptionedChart extends React.Component { ) } - @computed get isFaceted(): boolean { - const hasStrategy = - !!this.manager.facetStrategy && - this.manager.facetStrategy !== FacetStrategy.none - return !this.manager.isOnMapTab && hasStrategy - } - @computed get activeChartOrMapType(): GrapherChartOrMapType | undefined { const { manager } = this if (manager.isOnTableTab) return undefined @@ -211,6 +204,7 @@ export class CaptionedChart extends React.Component { activeChartOrMapType, containerElement, } = this + const { isFaceted } = manager if (!activeChartOrMapType) return @@ -219,7 +213,7 @@ export class CaptionedChart extends React.Component { activeChartOrMapType !== GRAPHER_MAP_TYPE ? activeChartOrMapType : undefined - if (this.isFaceted && activeChartType) + if (isFaceted && activeChartType) return ( 0 + const hoveredSeriesNames = props.hoveredSeriesNames + const isHoverModeActive = + props.isHoverModeActive ?? hoveredSeriesNames.length > 0 - const active = activeSeriesNames.includes(series.seriesName) - const background = isInteractionModeActive && !active + const active = hoveredSeriesNames.includes(series.seriesName) + const background = isHoverModeActive && !active return { active, background } } + +/** Useful for sorting series by their interaction state */ +export function byHoverThenFocusState(series: { + hover: InteractionState + focus: InteractionState +}): number { + // active series rank highest and hover trumps focus + if (series.hover.active) return 4 + if (series.focus.active) return 3 + + // series in their default state rank in the middle + if (!series.hover.background && !series.focus.background) return 2 + + // background series rank lowest + return 1 +} diff --git a/packages/@ourworldindata/grapher/src/color/ColorConstants.ts b/packages/@ourworldindata/grapher/src/color/ColorConstants.ts index 872602f59ed..edf4aa3ad74 100644 --- a/packages/@ourworldindata/grapher/src/color/ColorConstants.ts +++ b/packages/@ourworldindata/grapher/src/color/ColorConstants.ts @@ -15,9 +15,6 @@ export const GRAPHER_BACKGROUND_BEIGE = "#fbf9f3" export const GRAPHER_DARK_TEXT = GRAY_80 export const GRAPHER_LIGHT_TEXT = GRAY_70 -export const BACKGROUND_LINE_COLOR = GRAY_20 -export const BACKGROUND_TEXT_COLOR = GRAY_50 -export const BACKGROUND_DOT_COLOR = GRAY_30 - +export const OWID_NON_FOCUSED_GRAY = GRAY_30 export const OWID_NO_DATA_GRAY = "#6e7581" export const OWID_ERROR_COLOR = "ff0002" diff --git a/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts b/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts index 2e30e928629..9382f54e338 100644 --- a/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts +++ b/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts @@ -1,4 +1,4 @@ -import { EntityName } from "@ourworldindata/types" +import { EntityName, SeriesName } from "@ourworldindata/types" import { Url, performUrlMigrations, UrlMigration } from "@ourworldindata/utils" import { codeToEntityName, entityNameToCode } from "./EntityCodes" @@ -168,3 +168,30 @@ export const setSelectedEntityNamesParam = ( : undefined, }) } + +/* + * Focused series names + * + * A focused series name is one of: + * (i) an entity name (common case) + * (ii) an indicator name (less common, but not rare) + * (iii) a combination of both, typically represented as 'entityName – indicatorName' (rare) + * + * Parsing and serializing focused series names for the URL is done using utility + * functions that have originally been written for entity names, so that the same + * delimiter is used and entity names are mapped to their codes if possible. Note + * that stand-alone entity names are mapped to their codes (case i), while entity + * names that are a substring of a series name are not (case iii). + */ + +export const getFocusedSeriesNamesParam = ( + queryParam: string | undefined +): SeriesName[] | undefined => { + return queryParam !== undefined + ? entityNamesFromV2Param(queryParam).map(codeToEntityName) + : undefined +} + +export const generateFocusedSeriesNamesParam = ( + seriesNames: SeriesName[] +): string => entityNamesToV2Param(seriesNames.map(entityNameToCode)) diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts index 1b586576981..247950c185b 100755 --- a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts +++ b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts @@ -80,8 +80,9 @@ it("an empty Grapher serializes to an object that includes only the schema", () expect(new Grapher().toObject()).toEqual({ $schema: latestGrapherConfigSchema, - // TODO: ideally, selectedEntityNames is not serialised for an empty object + // TODO: ideally, these are not serialised for an empty object selectedEntityNames: [], + focusedSeriesNames: [], }) }) @@ -93,8 +94,9 @@ it("a bad chart type does not crash grapher", () => { ...input, $schema: latestGrapherConfigSchema, - // TODO: ideally, selectedEntityNames is not serialised for an empty object + // TODO: ideally, these are not serialised for an empty object selectedEntityNames: [], + focusedSeriesNames: [], }) }) @@ -102,8 +104,9 @@ it("does not preserve defaults in the object (except for the schema)", () => { expect(new Grapher({ tab: GRAPHER_TAB_OPTIONS.chart }).toObject()).toEqual({ $schema: latestGrapherConfigSchema, - // TODO: ideally, selectedEntityNames is not serialised for an empty object + // TODO: ideally, these are not serialised for an empty object selectedEntityNames: [], + focusedSeriesNames: [], }) }) diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 8306e96069e..726fd3ed455 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -19,7 +19,6 @@ import { next, sampleFrom, range, - difference, exposeInstanceOnWindow, findClosestTime, excludeUndefined, @@ -66,6 +65,7 @@ import { extractDetailsFromSyntax, omit, isTouchDevice, + isArrayDifferentFromReference, } from "@ourworldindata/utils" import { MarkdownTextWrap, @@ -111,6 +111,7 @@ import { GRAPHER_TAB_NAMES, GRAPHER_TAB_QUERY_PARAMS, GrapherTabOption, + SeriesName, } from "@ourworldindata/types" import { BlankOwidTable, @@ -144,7 +145,10 @@ import { import { TooltipManager } from "../tooltip/TooltipProps" import { DimensionSlot } from "../chart/DimensionSlot" -import { getSelectedEntityNamesParam } from "./EntityUrlBuilder" +import { + getFocusedSeriesNamesParam, + getSelectedEntityNamesParam, +} from "./EntityUrlBuilder" import { AxisConfig, AxisManager } from "../axis/AxisConfig" import { ColorScaleConfig } from "../color/ColorScaleConfig" import { MapConfig } from "../mapCharts/MapConfig" @@ -216,12 +220,14 @@ import { import { SlideInDrawer } from "../slideInDrawer/SlideInDrawer" import { BodyDiv } from "../bodyDiv/BodyDiv" import { grapherObjectToQueryParams } from "./GrapherUrl.js" +import { FocusArray } from "../focus/FocusArray" import { GRAPHER_BACKGROUND_BEIGE, GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, GRAPHER_LIGHT_TEXT, } from "../color/ColorConstants" +import { FacetChart } from "../facetChart/FacetChart" declare global { interface Window { @@ -439,6 +445,7 @@ export class Grapher are evaluated afterwards and can still remove entities even if they were included before. */ @observable includedEntities?: number[] = undefined + @observable focusedSeriesNames?: SeriesName[] = undefined @observable comparisonLines?: ComparisonLineConfig[] = undefined // todo: Persistables? @observable relatedQuestions?: RelatedQuestionsConfig[] = undefined // todo: Persistables? @@ -499,6 +506,15 @@ export class Grapher isEmbeddedInAnOwidPage?: boolean = this.props.isEmbeddedInAnOwidPage isEmbeddedInADataPage?: boolean = this.props.isEmbeddedInADataPage + selection = + this.manager?.selection ?? + new SelectionArray( + this.props.selectedEntityNames ?? [], + this.props.table?.availableEntities ?? [] + ) + + focusArray = new FocusArray() + /** * todo: factor this out and make more RAII. * @@ -565,6 +581,7 @@ export class Grapher ) obj.selectedEntityNames = this.selection.selectedEntityNames + obj.focusedSeriesNames = this.focusArray.seriesNames deleteRuntimeAndUnchangedProps(obj, defaultObject) @@ -606,6 +623,10 @@ export class Grapher if (obj.selectedEntityNames) this.selection.setSelectedEntities(obj.selectedEntityNames) + // update focus + if (obj.focusedSeriesNames) + this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames) + // JSON doesn't support Infinity, so we use strings instead. this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) @@ -675,13 +696,19 @@ export class Grapher if (region !== undefined) this.map.projection = region as MapProjectionName + // selection const selection = getSelectedEntityNamesParam( Url.fromQueryParams(params) ) - if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) this.selection.setSelectedEntities(selection) + // focus + const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus) + if (focusedSeriesNames) { + this.focusArray.clearAllAndAdd(...focusedSeriesNames) + } + // faceting if (params.facet && params.facet in FacetStrategy) { this.selectedFacetStrategy = params.facet as FacetStrategy @@ -857,6 +884,23 @@ export class Grapher return new ChartClass({ manager: this }) } + @computed get chartSeriesNames(): SeriesName[] { + if (!this.isReady) return [] + + // collect series names from all chart instances when faceted + if (this.isFaceted) { + const facetChartInstance = new FacetChart({ manager: this }) + return uniq( + facetChartInstance.intermediateChartInstances.flatMap( + (chartInstance) => + chartInstance.series.map((series) => series.seriesName) + ) + ) + } + + return this.chartInstance.series.map((series) => series.seriesName) + } + @computed get table(): OwidTable { return this.tableAfterAuthorTimelineFilter } @@ -2532,13 +2576,6 @@ export class Grapher void this.timelineController.togglePlay() } - selection = - this.manager?.selection ?? - new SelectionArray( - this.props.selectedEntityNames ?? [], - this.props.table?.availableEntities ?? [] - ) - @computed get availableEntities(): Entity[] { return this.tableForSelection.availableEntities } @@ -2784,6 +2821,11 @@ export class Grapher this.selectedFacetStrategy = facet } + @computed get isFaceted(): boolean { + const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none + return this.isOnChartTab && hasFacetStrategy + } + @action.bound randomSelection(num: number): void { // Continent, Population, GDP PC, GDP, PopDens, UN, Language, etc. this.clearErrors() @@ -3182,6 +3224,10 @@ export class Grapher ) } } + ), + reaction( + () => this.facetStrategy, + () => this.focusArray.clear() ) ) if (this.props.bindUrlToWindow) this.bindToWindow() @@ -3362,28 +3408,28 @@ export class Grapher return grapherObjectToQueryParams(this) } - @computed get selectedEntitiesIfDifferentThanAuthors(): - | EntityName[] - | undefined { + @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { const authoredConfig = this.legacyConfigAsAuthored - + const currentSelectedEntityNames = this.selection.selectedEntityNames const originalSelectedEntityNames = authoredConfig.selectedEntityNames ?? [] - const currentSelectedEntityNames = this.selection.selectedEntityNames - const entityNamesThatTheUserDeselected = difference( + return isArrayDifferentFromReference( currentSelectedEntityNames, originalSelectedEntityNames ) + } - if ( - currentSelectedEntityNames.length !== - originalSelectedEntityNames.length || - entityNamesThatTheUserDeselected.length - ) - return this.selection.selectedEntityNames + @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentFocusedSeriesNames = this.focusArray.seriesNames + const originalFocusedSeriesNames = + authoredConfig.focusedSeriesNames ?? [] - return undefined + return isArrayDifferentFromReference( + currentFocusedSeriesNames, + originalFocusedSeriesNames + ) } // Autocomputed url params to reflect difference between current grapher state diff --git a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts index fcbc1d8cdeb..de8d7f2ce19 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts @@ -4,7 +4,10 @@ import { GrapherQueryParams, TimeBoundValueStr, } from "@ourworldindata/types" -import { generateSelectedEntityNamesParam } from "./EntityUrlBuilder.js" +import { + generateFocusedSeriesNamesParam, + generateSelectedEntityNamesParam, +} from "./EntityUrlBuilder.js" import { match } from "ts-pattern" import { Grapher } from "./Grapher.js" @@ -53,6 +56,9 @@ export const grapherConfigToQueryParams = ( country: config.selectedEntityNames ? generateSelectedEntityNamesParam(config.selectedEntityNames) : undefined, + focus: config.focusedSeriesNames + ? generateFocusedSeriesNamesParam(config.focusedSeriesNames) + : undefined, // These cannot be specified in config, so we always set them to undefined showSelectionOnlyInTable: undefined, @@ -92,11 +98,14 @@ export const grapherObjectToQueryParams = ( ? "1" : "0", showNoDataArea: grapher.showNoDataArea ? "1" : "0", - country: grapher.selectedEntitiesIfDifferentThanAuthors + country: grapher.areSelectedEntitiesDifferentThanAuthors ? generateSelectedEntityNamesParam( - grapher.selectedEntitiesIfDifferentThanAuthors + grapher.selection.selectedEntityNames ) : undefined, + focus: grapher.areFocusedSeriesNamesDifferentThanAuthors + ? generateFocusedSeriesNamesParam(grapher.focusArray.seriesNames) + : undefined, } return params } diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index a6590dcf0be..3d547020bbc 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -50,6 +50,7 @@ import { ColumnSlug, OwidColumnDef } from "@ourworldindata/types" import { buildVariableTable } from "../core/LegacyToOwidTable" import { loadVariableDataAndMetadata } from "../core/loadVariable" import { DrawerContext } from "../slideInDrawer/SlideInDrawer.js" +import { FocusArray } from "../focus/FocusArray" export interface EntitySelectorState { searchInput: string @@ -70,6 +71,7 @@ export interface EntitySelectorManager { isEntitySelectorModalOrDrawerOpen?: boolean canChangeEntity?: boolean canHighlightEntities?: boolean + focusArray?: FocusArray } interface SortConfig { @@ -603,6 +605,11 @@ export class EntitySelector extends React.Component<{ this.selectionArray.setSelectedEntities([entityName]) } + // remove focus from an entity that has been removed from the selection + if (!this.selectionArray.selectedSet.has(entityName)) { + this.manager.focusArray?.remove(entityName) + } + this.clearSearchInput() // close the modal or drawer automatically after selection if in single mode @@ -618,11 +625,12 @@ export class EntitySelector extends React.Component<{ const { partitionedSearchResults } = this if (this.searchInput) { const { selected = [] } = partitionedSearchResults ?? {} - this.selectionArray.deselectEntities( - selected.map((entity) => entity.name) - ) + const entityNames = selected.map((entity) => entity.name) + this.selectionArray.deselectEntities(entityNames) + this.manager.focusArray?.remove(...entityNames) } else { this.selectionArray.clearSelection() + this.manager.focusArray?.clear() } } diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 43ca975d287..ef091fb9110 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -266,6 +266,7 @@ export class FacetChart endTime, missingDataStrategy, backgroundColor, + focusArray, } = manager // Use compact labels, e.g. 50k instead of 50,000. @@ -323,6 +324,7 @@ export class FacetChart missingDataStrategy, backgroundColor, hideNoDataSection, + focusArray, ...series.manager, xAxisConfig: { ...globalXAxisConfig, @@ -345,7 +347,7 @@ export class FacetChart }) } - @computed private get intermediateChartInstances(): ChartInterface[] { + @computed get intermediateChartInstances(): ChartInterface[] { return this.intermediatePlacedSeries.map(({ bounds, manager }) => { const ChartClass = ChartComponentClassMap.get(this.chartTypeName) ?? @@ -730,6 +732,22 @@ export class FacetChart return [this.legendHoverBin.color] } + @computed get activeColors(): Color[] | undefined { + const { focusArray } = this.manager + if (!focusArray) return undefined + + // find colours of all currently focused series + const activeColors = uniq( + this.intermediateChartInstances.flatMap((chartInstance) => + chartInstance.series + .filter((series) => focusArray.has(series.seriesName)) + .map((series) => series.color) + ) + ) + + return activeColors.length > 0 ? activeColors : undefined + } + @computed get numericLegendData(): ColorScaleBin[] { if (!this.isNumericLegend || !this.hideFacetLegends) return [] const allBins: ColorScaleBin[] = this.externalLegends.flatMap( @@ -780,6 +798,19 @@ export class FacetChart this.legendHoverBin = undefined } + @action.bound onLegendClick(bin: ColorScaleBin): void { + if (!this.manager.focusArray) return + // find all series (of all facets) that are contained in the bin + const seriesNames = uniq( + this.intermediateChartInstances.flatMap((chartInstance) => + chartInstance.series + .filter((series) => bin.contains(series.seriesName)) + .map((series) => series.seriesName) + ) + ) + this.manager.focusArray.toggle(...seriesNames) + } + // end of legend props @computed private get legend(): HorizontalColorLegend { diff --git a/packages/@ourworldindata/grapher/src/focus/FocusArray.test.ts b/packages/@ourworldindata/grapher/src/focus/FocusArray.test.ts new file mode 100644 index 00000000000..504e0abd19a --- /dev/null +++ b/packages/@ourworldindata/grapher/src/focus/FocusArray.test.ts @@ -0,0 +1,57 @@ +#! /usr/bin/env jest + +import { FocusArray } from "./FocusArray" + +const seriesNames = ["Europe", "USA", "China"] + +it("can create a focus array", () => { + const focusArray = new FocusArray() + expect(focusArray.isEmpty).toEqual(true) +}) + +it("an active series is also in the foreground", () => { + // all series are currently focused + const focusArray = new FocusArray() + focusArray.add(...seriesNames) + + // all series are active and in the foreground + for (const seriesName of seriesNames) { + expect(focusArray.has(seriesName)).toEqual(true) + expect(focusArray.isInForeground(seriesName)).toEqual(true) + } +}) + +it("a foreground series is not necessarily active", () => { + // no series is currently focused + const focusArray = new FocusArray() + + // all series are in the foreground but not active + for (const seriesName of seriesNames) { + expect(focusArray.isInForeground(seriesName)).toEqual(true) + expect(focusArray.has(seriesName)).toEqual(false) + } +}) + +it("a series can't be in the foreground and background at the same time", () => { + // a subset of series is currently focused + const focusArray = new FocusArray() + focusArray.add(seriesNames[0]) + + // all series are either in the foreground or background, but not both + for (const seriesName of seriesNames) { + expect(focusArray.isInForeground(seriesName)).not.toEqual( + focusArray.isInBackground(seriesName) + ) + } +}) + +it("can toggle focus state", () => { + const focusArray = new FocusArray() + const example = seriesNames[0] + + expect(focusArray.has(example)).toEqual(false) + focusArray.toggle(example) + expect(focusArray.has(example)).toEqual(true) + focusArray.toggle(example) + expect(focusArray.has(example)).toEqual(false) +}) diff --git a/packages/@ourworldindata/grapher/src/focus/FocusArray.ts b/packages/@ourworldindata/grapher/src/focus/FocusArray.ts new file mode 100644 index 00000000000..1e9841dfe48 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/focus/FocusArray.ts @@ -0,0 +1,95 @@ +import { SeriesName, InteractionState } from "@ourworldindata/types" +import { action, computed, observable } from "mobx" + +export class FocusArray { + constructor() { + this.store = new Set() + } + + @observable private store: Set + + @computed get seriesNameSet(): Set { + return this.store + } + + @computed get seriesNames(): SeriesName[] { + return Array.from(this.store) + } + + @computed get isEmpty(): boolean { + return this.store.size === 0 + } + + /** + * Whether a series is currently focused + */ + has(seriesName: SeriesName): boolean { + return this.store.has(seriesName) + } + + /** + * Whether a series is in the foreground, i.e. either + * the chart isn't currently in focus mode (in which + * all series are in the foreground by default) or the + * series itself is currently focused. + */ + isInForeground(seriesName: SeriesName): boolean { + return this.isEmpty || this.has(seriesName) + } + + /** + * Whether a series is in the background, i.e. the chart + * is currently in focus mode but the given series isn't + * focused. + */ + isInBackground(seriesName: SeriesName): boolean { + return !this.isEmpty && !this.has(seriesName) + } + + /** + * Get the interaction state of a series: + * - active: true if the series is currently focused + * - background: true if another series is currently focused + */ + state(seriesName: SeriesName): InteractionState { + return { + active: this.has(seriesName), + background: this.isInBackground(seriesName), + } + } + + @action.bound add(...seriesNames: SeriesName[]): this { + for (const seriesName of seriesNames) { + this.store.add(seriesName) + } + return this + } + + @action.bound remove(...seriesNames: SeriesName[]): this { + for (const seriesName of seriesNames) { + this.store.delete(seriesName) + } + return this + } + + @action.bound clearAllAndAdd(...seriesNames: SeriesName[]): this { + this.clear() + this.add(...seriesNames) + return this + } + + @action.bound toggle(...seriesNames: SeriesName[]): this { + for (const seriesName of seriesNames) { + if (this.has(seriesName)) { + this.remove(seriesName) + } else { + this.add(seriesName) + } + } + return this + } + + @action.bound clear(): void { + this.store.clear() + } +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx index 6595d83d192..d53b679238a 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx @@ -30,6 +30,7 @@ import { GRAPHER_OPACITY_MUTE, } from "../core/GrapherConstants" import { darkenColorForLine } from "../color/ColorUtils" +import { OWID_NON_FOCUSED_GRAY } from "../color/ColorConstants" export interface PositionedBin { x: number @@ -862,7 +863,9 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { : mark.bin.color const fill = - isActive || activeColors === undefined ? color : "#ccc" + isActive || activeColors === undefined + ? color + : OWID_NON_FOCUSED_GRAY const opacity = isNotHovered ? GRAPHER_OPACITY_MUTE diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 7e5ee3ca2d5..516a4ddae56 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -81,9 +81,9 @@ import { import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, - byInteractionState, + byHoverThenFocusState, getDefaultFailMessage, - getInteractionStateForSeries, + getHoverStateForSeries, getSeriesKey, isTargetOutsideElement, makeClipPath, @@ -97,6 +97,8 @@ import { ColorScaleConfig } from "../color/ColorScaleConfig" import { GRAPHER_BACKGROUND_DEFAULT, OWID_NO_DATA_GRAY, + GRAY_50, + OWID_NON_FOCUSED_GRAY, } from "../color/ColorConstants" import { MultiColorPolyline } from "../scatterCharts/MultiColorPolyline" import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" @@ -112,11 +114,12 @@ import { getColorKey, getSeriesName, } from "./LineChartHelpers" +import { FocusArray } from "../focus/FocusArray.js" const LINE_CHART_CLASS_NAME = "LineChart" // line color -const BLUR_LINE_COLOR = "#eee" +const NON_FOCUSED_LINE_COLOR = OWID_NON_FOCUSED_GRAY const DEFAULT_LINE_COLOR = "#000" // stroke width const DEFAULT_STROKE_WIDTH = 1.5 @@ -165,17 +168,28 @@ class Lines extends React.Component { } private seriesHasMarkers(series: RenderLineChartSeries): boolean { - return !series.hover.background && !series.isProjection + if (series.hover.background || series.isProjection) return false + return !series.focus.background || series.hover.active } private renderLine(series: RenderLineChartSeries): React.ReactElement { - const { hover } = series + const { hover, focus } = series - const stroke = 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 seriesColor = series.placedPoints[0]?.color ?? DEFAULT_LINE_COLOR + const color = + !focus.background || hover.active + ? seriesColor + : NON_FOCUSED_LINE_COLOR + const strokeDasharray = series.isProjection ? "2,3" : undefined + const strokeWidth = + hover.background || focus.background + ? 0.66 * this.strokeWidth + : this.strokeWidth + const strokeOpacity = + hover.background && !focus.background ? GRAPHER_OPACITY_MUTE : 1 + + const showOutline = !focus.background || hover.active const outlineColor = this.props.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT const outlineWidth = strokeWidth + this.lineOutlineWidth * 2 @@ -185,40 +199,35 @@ class Lines extends React.Component { id={makeIdForHumanConsumption("outline", series.seriesName)} placedPoints={series.placedPoints} stroke={outlineColor} - strokeWidth={outlineWidth} + strokeWidth={outlineWidth.toFixed(1)} /> ) - if (this.props.multiColor) { - return ( - <> - {outline} - - - ) - } - - return ( - <> - {outline} + const line = + this.props.multiColor && !focus.background ? ( + + ) : ( + ) + + return ( + <> + {showOutline && outline} + {line} ) } @@ -227,6 +236,7 @@ class Lines extends React.Component { series: RenderLineChartSeries ): React.ReactElement | void { const { horizontalAxis } = this.props.dualAxis + const { hover, focus } = 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. @@ -237,23 +247,31 @@ class Lines extends React.Component { if (hideMarkers && !forceMarkers) return - const opacity = series.hover.background ? GRAPHER_OPACITY_MUTE : 1 + const opacity = + hover.background && !focus.background ? GRAPHER_OPACITY_MUTE : 1 return ( - {series.placedPoints.map((value, index) => ( - - ))} + {series.placedPoints.map((value, index) => { + const valueColor = value.color + const color = + !focus.background || hover.active + ? valueColor + : NON_FOCUSED_LINE_COLOR + return ( + + ) + })} ) } @@ -512,8 +530,8 @@ export class LineChart return makeSelectionArray(this.manager.selection) } - seriesIsBlurred(series: LineChartSeries): boolean { - return this.hoverStateForSeries(series).background + @computed get focusArray(): FocusArray { + return this.manager.focusArray ?? new FocusArray() } @computed get activeX(): number | undefined { @@ -538,11 +556,21 @@ export class LineChart y2={verticalAxis.range[1]} stroke="rgba(180,180,180,.4)" /> - {this.series.map((series) => { + {this.renderSeries.map((series) => { const value = series.points.find( (point) => point.x === activeX ) - if (!value || this.seriesIsBlurred(series)) return null + if (!value || series.hover.background) return null + + const valueColor = this.hasColorScale + ? darkenColorForLine( + this.getColorScaleColor(value.colorValue) + ) + : series.color + const color = + !series.focus.background || series.hover.active + ? valueColor + : GRAY_50 return ( 1 + ? this.onLineLegendClick + : undefined + } /> )} { return { ...series, hover: this.hoverStateForSeries(series), + focus: this.focusStateForSeries(series), } } ) - // sort by interaction state so that hovered series + // sort by interaction state so that foreground series // are drawn on top of background series - if (this.isHoverModeActive) { - return sortBy(series, byInteractionState) + if (this.isHoverModeActive || this.isFocusModeActive) { + return sortBy(series, byHoverThenFocusState) } return series @@ -1343,6 +1383,7 @@ export class LineChart ), yValue: lastValue, hover: this.hoverStateForSeries(series), + focus: this.focusStateForSeries(series), } }) } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts index bb934281dff..8ee26f4622a 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts @@ -32,6 +32,7 @@ export interface PlacedLineChartSeries extends LineChartSeries { export interface RenderLineChartSeries extends PlacedLineChartSeries { hover: InteractionState + focus: InteractionState } export interface LinesProps { diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts index 49a1f81597a..62c917b239c 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts @@ -9,6 +9,10 @@ import { export type AnnotationsMap = Map> +/** + * Unique identifier for a series that must be shared between + * line and slope charts since focus states are built on top of it. + */ export function getSeriesName({ entityName, columnName, diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 7bdb5ae97f8..33c84a512e3 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -13,6 +13,7 @@ import { sortedIndexBy, last, maxBy, + partition, } from "@ourworldindata/utils" import { TextWrap, TextWrapGroup, Halo } from "@ourworldindata/components" import { computed } from "mobx" @@ -29,8 +30,14 @@ import { BASE_FONT_SIZE, GRAPHER_FONT_SCALE_12 } from "../core/GrapherConstants" import { ChartSeries } from "../chart/ChartInterface" import { darkenColorForText } from "../color/ColorUtils" import { AxisConfig } from "../axis/AxisConfig.js" -import { GRAPHER_BACKGROUND_DEFAULT } from "../color/ColorConstants" +import { + GRAPHER_BACKGROUND_DEFAULT, + GRAY_30, + GRAY_70, +} from "../color/ColorConstants" +// text color for labels of background series +const NON_FOCUSED_TEXT_COLOR = GRAY_70 // Minimum vertical space between two legend items const LEGEND_ITEM_MIN_SPACING = 4 // Horizontal distance from the end of the chart to the start of the marker @@ -49,13 +56,15 @@ export interface LineLabelSeries extends ChartSeries { placeFormattedValueInNewLine?: boolean yRange?: [number, number] hover?: InteractionState + focus?: InteractionState } interface SizedSeries extends LineLabelSeries { - textWrap: TextWrapGroup + textWrap: TextWrap | TextWrapGroup annotationTextWrap?: TextWrap width: number height: number + fontWeight?: number } interface PlacedSeries extends SizedSeries { @@ -96,6 +105,7 @@ class LineLabels extends React.Component<{ textOutlineColor?: Color anchor?: "start" | "end" isStatic?: boolean + cursor?: string onClick?: (series: PlacedSeries) => void onMouseOver?: (series: PlacedSeries) => void onMouseLeave?: (series: PlacedSeries) => void @@ -113,7 +123,7 @@ class LineLabels extends React.Component<{ } private textOpacityForSeries(series: PlacedSeries): number { - return series.hover?.background ? 0.6 : 1 + return series.hover?.background && !series.focus?.background ? 0.6 : 1 } @computed private get markers(): { @@ -149,17 +159,37 @@ class LineLabels extends React.Component<{ return ( {this.markers.map(({ series, labelText }) => { - const textColor = darkenColorForText(series.color) - return ( + const textColor = + !series.focus?.background || series.hover?.active + ? darkenColorForText(series.color) + : NON_FOCUSED_TEXT_COLOR + const textProps = { + fill: textColor, + opacity: this.textOpacityForSeries(series), + textAnchor: this.anchor, + } + + return series.textWrap instanceof TextWrap ? ( + + {series.textWrap.render(labelText.x, labelText.y, { + textProps: { + ...textProps, + // might override the textWrap's fontWeight + fontWeight: series.fontWeight, + }, + })} + + ) : ( {series.textWrap.render(labelText.x, labelText.y, { showTextOutline: this.showTextOutline, textOutlineColor: this.textOutlineColor, - textProps: { - fill: textColor, - opacity: this.textOpacityForSeries(series), - textAnchor: this.anchor, - }, + textProps, })} ) @@ -222,7 +252,11 @@ 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 = series.hover?.background ? "#eee" : "#999" + const lineColor = series.hover?.background + ? "#eee" + : series.focus?.background + ? GRAY_30 + : "#999" return ( this.props.onClick?.(series)} - style={{ cursor: "default" }} + style={{ cursor: this.props.cursor }} > { return this.props.verticalAlign ?? VerticalAlign.middle } - @computed.struct get sizedLabels(): SizedSeries[] { - const { fontSize, maxWidth } = this - const maxTextWidth = maxWidth - DEFAULT_CONNECTOR_LINE_WIDTH - const maxAnnotationWidth = Math.min(maxTextWidth, 150) + @computed private get textMaxWidth(): number { + return this.maxWidth - DEFAULT_CONNECTOR_LINE_WIDTH + } + + private makeLabelTextWrap( + series: LineLabelSeries + ): TextWrap | TextWrapGroup { + if (!series.formattedValue) { + return new TextWrap({ + text: series.label, + maxWidth: this.textMaxWidth, + fontSize: this.fontSize, + // using the actual font weight here would lead to a jumpy layout + // when focusing/unfocusing a series since focused series are + // bolded and the computed text width depends on the text's font weight. + // that's why we always use bold labels to comupte the layout, + // but might render them later using a regular font weight. + fontWeight: 700, + }) + } + + // text label fragment + const textLabel = { text: series.label, fontWeight: 700 } + + // value label fragment + const newLine = series.placeFormattedValueInNewLine + ? "always" + : "avoid-wrap" + const valueLabel = { + text: series.formattedValue, + fontWeight: 400, + newLine, + } + return new TextWrapGroup({ + fragments: [textLabel, valueLabel], + maxWidth: this.textMaxWidth, + fontSize: this.fontSize, + }) + } + + private makeAnnotationTextWrap( + series: LineLabelSeries + ): TextWrap | undefined { + if (!series.annotation) return undefined + const maxWidth = Math.min(this.textMaxWidth, 150) + return new TextWrap({ + text: series.annotation, + maxWidth, + fontSize: this.fontSize * 0.9, + lineHeight: 1, + }) + } + + @computed.struct get sizedLabels(): SizedSeries[] { + const { fontWeight: globalFontWeight } = this return this.props.labelSeries.map((label) => { - // if a formatted value is given, make the main label bold - const fontWeight = label.formattedValue ? 700 : this.fontWeight - - const mainLabel = { text: label.label, fontWeight } - const valueLabel = label.formattedValue - ? { - text: label.formattedValue, - newLine: (label.placeFormattedValueInNewLine - ? "always" - : "avoid-wrap") as "always" | "avoid-wrap", - } - : undefined - const labelFragments = excludeUndefined([mainLabel, valueLabel]) - const textWrap = new TextWrapGroup({ - fragments: labelFragments, - maxWidth: maxTextWidth, - fontSize, - }) - const annotationTextWrap = label.annotation - ? new TextWrap({ - text: label.annotation, - maxWidth: maxAnnotationWidth, - fontSize: fontSize * 0.9, - lineHeight: 1, - }) - : undefined - - const annotationWidth = annotationTextWrap - ? annotationTextWrap.width - : 0 + const textWrap = this.makeLabelTextWrap(label) + const annotationTextWrap = this.makeAnnotationTextWrap(label) + + const annotationWidth = annotationTextWrap?.width ?? 0 const annotationHeight = annotationTextWrap ? ANNOTATION_PADDING + annotationTextWrap.height : 0 + // font weight priority: + // series focus state > presense of value label > globally set font weight + const activeFontWeight = label.focus?.active ? 700 : undefined + const seriesFontWeight = label.formattedValue ? 700 : undefined + const fontWeight = + activeFontWeight ?? seriesFontWeight ?? globalFontWeight + return { ...label, textWrap, annotationTextWrap, width: Math.max(textWrap.width, annotationWidth), height: textWrap.height + annotationHeight, + fontWeight, } }) } @@ -509,10 +576,7 @@ export class LineLegend extends React.Component { const [yLegendMin, yLegendMax] = this.legendY // ensure list is sorted by the visual position in ascending order - const sortedSeries = sortBy( - this.partialInitialSeries, - (label) => label.midY - ) + const sortedSeries = sortBy(this.visibleSeries, (label) => label.midY) const groups: PlacedSeries[][] = cloneDeep(sortedSeries).map((mark) => [ mark, @@ -589,7 +653,7 @@ export class LineLegend extends React.Component { ) } - @computed get partialInitialSeries(): PlacedSeries[] { + @computed get visibleSeries(): PlacedSeries[] { const { legendY } = this const availableHeight = Math.abs(legendY[1] - legendY[0]) const nonOverlappingMinHeight = @@ -663,17 +727,66 @@ export class LineLegend extends React.Component { return [undefined, undefined] } - const sortedCandidates = sortBy(this.initialSeries, (c) => c.midY) + const [focusedCandidates, nonFocusedCandidates] = partition( + this.initialSeries, + (series) => series.focus?.active + ) - // pick two candidates, one from the top and one from the bottom - const midIndex = Math.floor((sortedCandidates.length - 1) / 2) - for (let startIndex = 0; startIndex <= midIndex; startIndex++) { - const endIndex = sortedCandidates.length - 1 - startIndex - maybePickCandidate(sortedCandidates[endIndex]) - if (sortedKeepSeries.length >= 2 || startIndex === endIndex) - break - maybePickCandidate(sortedCandidates[startIndex]) - if (sortedKeepSeries.length >= 2) break + // pick focused canidates first + while (focusedCandidates.length > 0) { + const focusedCandidate = focusedCandidates.pop()! + const picked = maybePickCandidate(focusedCandidate) + + // if one of the focused candidates doesn't fit, + // remove it from the candidates and continue + if (!picked) candidates.delete(focusedCandidate) + } + + // we initially need to pick at least two candidates. + // - if we already picked two from the set of focused series, + // we're done + // - if we picked only one focused series, then we pick another + // one from the set of non-focused series. we pick the one that + // is furthest away from the focused one + // - if we haven't picked any focused series, we pick two from + // the non-focused series, one from the top and one from the bottom + if (sortedKeepSeries.length === 0) { + // sort the remaining candidates by their position + const sortedCandidates = sortBy( + nonFocusedCandidates, + (c) => c.midY + ) + + // pick two candidates, one from the top and one from the bottom + const midIndex = Math.floor((sortedCandidates.length - 1) / 2) + for (let startIndex = 0; startIndex <= midIndex; startIndex++) { + const endIndex = sortedCandidates.length - 1 - startIndex + maybePickCandidate(sortedCandidates[endIndex]) + if (sortedKeepSeries.length >= 2 || startIndex === endIndex) + break + maybePickCandidate(sortedCandidates[startIndex]) + if (sortedKeepSeries.length >= 2) break + } + } else if (sortedKeepSeries.length === 1) { + const keepMidY = sortedKeepSeries[0].midY + + while (nonFocusedCandidates.length > 0) { + // prefer the candidate that is furthest away from the one + // that was already picked + const candidate = maxBy(nonFocusedCandidates, (c) => + Math.abs(c.midY - keepMidY) + )! + const cIndex = nonFocusedCandidates.indexOf(candidate) + if (cIndex > -1) nonFocusedCandidates.splice(cIndex, 1) + + // we only need one more candidate, so if we find one, we're done + const picked = maybePickCandidate(candidate) + if (picked) break + + // if the candidate wasn't picked, remove it from the + // candidates and continue + candidates.delete(candidate) + } } while (candidates.size > 0 && keepSeriesHeight <= availableHeight) { @@ -731,7 +844,7 @@ export class LineLegend extends React.Component { } @computed get visibleSeriesNames(): SeriesName[] { - return this.partialInitialSeries.map((series) => series.seriesName) + return this.visibleSeries.map((series) => series.seriesName) } // Does this placement need line markers or is the position of the labels already clear? @@ -763,6 +876,7 @@ export class LineLegend extends React.Component { onMouseLeave={(series): void => this.onMouseLeave(series.seriesName) } + cursor={this.props.onClick ? "pointer" : "default"} /> ) diff --git a/packages/@ourworldindata/grapher/src/schema/grapher-schema.006.yaml b/packages/@ourworldindata/grapher/src/schema/grapher-schema.006.yaml index 67ae88895c5..14a7df9ded1 100644 --- a/packages/@ourworldindata/grapher/src/schema/grapher-schema.006.yaml +++ b/packages/@ourworldindata/grapher/src/schema/grapher-schema.006.yaml @@ -104,6 +104,14 @@ properties: items: type: - string + focusedSeriesNames: + type: array + description: | + The initially focused chart elements. Is either a list of entity or variable names. + Only works for line and slope charts for now. + items: + type: + - string baseColorScheme: type: string description: | diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index c49edd68c25..272dd3b8df2 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -50,9 +50,9 @@ import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, - byInteractionState, + byHoverThenFocusState, getDefaultFailMessage, - getInteractionStateForSeries, + getHoverStateForSeries, getShortNameForEntity, makeSelectionArray, } from "../chart/ChartUtils" @@ -88,9 +88,11 @@ import { Halo } from "@ourworldindata/components" import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" import { CategoricalBin } from "../color/ColorScaleBin" import { + OWID_NON_FOCUSED_GRAY, GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, } from "../color/ColorConstants" +import { FocusArray } from "../focus/FocusArray" type SVGMouseOrTouchEvent = | React.MouseEvent @@ -102,8 +104,9 @@ export interface SlopeChartManager extends ChartManager { hideNoDataSection?: boolean } -const TOP_PADDING = 6 // leave room for overflowing dots +const NON_FOCUSED_LINE_COLOR = OWID_NON_FOCUSED_GRAY +const TOP_PADDING = 6 // leave room for overflowing dots const LINE_LEGEND_PADDING = 4 @observer @@ -226,6 +229,10 @@ export class SlopeChart return makeSelectionArray(this.manager.selection) } + @computed get focusArray(): FocusArray { + return this.manager.focusArray ?? new FocusArray() + } + @computed private get formatColumn(): CoreColumn { return this.yColumns[0] } @@ -248,6 +255,10 @@ export class SlopeChart ) } + @computed get isFocusModeActive(): boolean { + return !this.focusArray.isEmpty + } + @computed private get yColumns(): CoreColumn[] { return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) } @@ -319,27 +330,25 @@ export class SlopeChart const { canSelectMultipleEntities = false } = this.manager const { availableEntityNames } = this.transformedTable - const displayEntityName = - getShortNameForEntity(entityName) ?? entityName const columnName = column.nonEmptyDisplayName - const seriesName = getSeriesName({ - entityName: displayEntityName, + const props = { + entityName, columnName, seriesStrategy, availableEntityNames, canSelectMultipleEntities, + } + const seriesName = getSeriesName(props) + const displayName = getSeriesName({ + ...props, + entityName: getShortNameForEntity(entityName) ?? entityName, }) const owidRowByTime = column.owidRowByEntityNameAndTime.get(entityName) const start = owidRowByTime?.get(startTime) const end = owidRowByTime?.get(endTime) - const colorKey = getColorKey({ - entityName, - columnName, - seriesStrategy, - availableEntityNames, - }) + const colorKey = getColorKey(props) const color = this.categoricalColorAssigner.assign(colorKey) const annotation = getAnnotationsForSeries( @@ -351,6 +360,7 @@ export class SlopeChart column, seriesName, entityName, + displayName, color, start, end, @@ -456,26 +466,31 @@ export class SlopeChart } private hoverStateForSeries(series: SlopeChartSeries): InteractionState { - return getInteractionStateForSeries(series, { - isInteractionModeActive: this.isHoverModeActive, - activeSeriesNames: this.hoveredSeriesNames, + return getHoverStateForSeries(series, { + isHoverModeActive: this.isHoverModeActive, + hoveredSeriesNames: this.hoveredSeriesNames, }) } + private focusStateForSeries(series: SlopeChartSeries): InteractionState { + return this.focusArray.state(series.seriesName) + } + @computed private get renderSeries(): RenderSlopeChartSeries[] { const series: RenderSlopeChartSeries[] = this.placedSeries.map( (series) => { return { ...series, hover: this.hoverStateForSeries(series), + focus: this.focusStateForSeries(series), } } ) - // sort by interaction state so that hovered series + // sort by interaction state so that foreground series // are drawn on top of background series - if (this.isHoverModeActive) { - return sortBy(series, byInteractionState) + if (this.isHoverModeActive || this.isFocusModeActive) { + return sortBy(series, byHoverThenFocusState) } return series @@ -624,6 +639,8 @@ export class SlopeChart textOutlineColor: this.backgroundColor, onMouseOver: this.onLineLegendMouseOver, onMouseLeave: this.onLineLegendMouseLeave, + onClick: + this.series.length > 1 ? this.onLineLegendClick : undefined, } } @@ -715,6 +732,13 @@ export class SlopeChart const PREFER_S1 = -1 const PREFER_S2 = 1 + const s1_isFocused = this.focusArray.has(s1) + const s2_isFocused = this.focusArray.has(s2) + + // prefer to label focused series + if (s1_isFocused && !s2_isFocused) return PREFER_S1 + if (s2_isFocused && !s1_isFocused) return PREFER_S2 + const s1_isLabelled = this.visibleLineLegendLabelsRight.has(s1) const s2_isLabelled = this.visibleLineLegendLabelsRight.has(s2) @@ -811,18 +835,19 @@ export class SlopeChart showAnnotation?: boolean } ): LineLabelSeries { - const { seriesName, color, annotation } = series + const { seriesName, displayName, color, annotation } = series const value = getValue(series) const formattedValue = this.formatValue(value) return { color, seriesName, annotation: showAnnotation ? annotation : undefined, - label: showSeriesName ? seriesName : formattedValue, + label: showSeriesName ? displayName : formattedValue, formattedValue: showSeriesName ? formattedValue : undefined, placeFormattedValueInNewLine: this.useCompactLayout, yValue: value, hover: this.hoverStateForSeries(series), + focus: this.focusStateForSeries(series), } } @@ -939,6 +964,10 @@ export class SlopeChart }, 200) } + @action.bound onLineLegendClick(seriesName: SeriesName): void { + this.focusArray.toggle(seriesName) + } + @action.bound onSlopeMouseOver(series: SlopeChartSeries): void { this.hoveredSeriesName = series.seriesName this.tooltipState.target = { series } @@ -1056,18 +1085,18 @@ export class SlopeChart } private makeMissingDataLabel(series: RawSlopeChartSeries): string { - const { seriesName, start, end } = series + const { displayName, start, end } = series const startTime = this.formatColumn.formatTime(this.startTime) const endTime = this.formatColumn.formatTime(this.endTime) // mention the start or end value if they're missing if (start?.value === undefined && end?.value === undefined) { - return `${seriesName} (${startTime} & ${endTime})` + return `${displayName} (${startTime} & ${endTime})` } else if (start?.value === undefined) { - return `${seriesName} (${startTime})` + return `${displayName} (${startTime})` } else if (end?.value === undefined) { - return `${seriesName} (${endTime})` + return `${displayName} (${endTime})` } // if both values are given but the series shows up in the No Data @@ -1078,14 +1107,14 @@ export class SlopeChart start.originalTime !== this.startTime const isToleranceAppliedToEndValue = end.originalTime !== this.endTime if (isToleranceAppliedToStartValue && isToleranceAppliedToEndValue) { - return `${seriesName} (${startTime} & ${endTime})` + return `${displayName} (${startTime} & ${endTime})` } else if (isToleranceAppliedToStartValue) { - return `${seriesName} (${startTime})` + return `${displayName} (${startTime})` } else if (isToleranceAppliedToEndValue) { - return `${seriesName} (${endTime})` + return `${displayName} (${endTime})` } - return seriesName + return displayName } private renderNoDataSection(): React.ReactElement | void { @@ -1117,7 +1146,6 @@ export class SlopeChart void - onMouseLeave?: () => void } function Slope({ series, - color, dotRadius = 2.5, strokeWidth = 2, outlineWidth = 0.5, outlineStroke = "#fff", - onMouseOver, - onMouseLeave, }: SlopeProps) { - const { seriesName, startPoint, endPoint } = series - - const showOutline = !series.hover.background - const opacity = series.hover.background ? GRAPHER_OPACITY_MUTE : 1 + const { seriesName, startPoint, endPoint, hover, focus } = series + + const showOutline = !focus.background || hover.active + const opacity = + hover.background && !focus.background ? GRAPHER_OPACITY_MUTE : 1 + const color = + !focus.background || hover.active + ? series.color + : NON_FOCUSED_LINE_COLOR + const lineWidth = + hover.background || focus.background ? 0.66 * strokeWidth : strokeWidth return ( onMouseOver?.(series)} - onMouseLeave={() => onMouseLeave?.()} > {showOutline && ( )} @@ -1367,7 +1394,7 @@ function LineWithDots({ d={`${startDotPath} ${endDotPath} ${linePath}`} fill={color} stroke={color} - strokeWidth={lineWidth} + strokeWidth={lineWidth.toFixed(1)} opacity={opacity} /> ) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts index b0986968e15..1e5c904b071 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts @@ -10,6 +10,7 @@ import { CoreColumn } from "@ourworldindata/core-table" export interface SlopeChartSeries extends ChartSeries { column: CoreColumn entityName: EntityName + displayName: string start: Pick, "value" | "originalTime"> end: Pick, "value" | "originalTime"> annotation?: string @@ -24,4 +25,5 @@ export interface PlacedSlopeChartSeries extends SlopeChartSeries { export interface RenderSlopeChartSeries extends PlacedSlopeChartSeries { hover: InteractionState + focus: InteractionState } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 8ab4c9b6f10..fe583929ff2 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -53,7 +53,7 @@ import { stackSeries, withMissingValuesAsZeroes } from "./StackedUtils" import { makeClipPath, isTargetOutsideElement, - getInteractionStateForSeries, + getHoverStateForSeries, } from "../chart/ChartUtils" import { bind } from "decko" import { AxisConfig } from "../axis/AxisConfig.js" @@ -301,9 +301,9 @@ export class StackedAreaChart extends AbstractStackedChart { private hoverStateForSeries( series: StackedSeries ): InteractionState { - return getInteractionStateForSeries(series, { - isInteractionModeActive: this.isHoverModeActive, - activeSeriesNames: this.hoveredSeriesNames, + return getHoverStateForSeries(series, { + isHoverModeActive: this.isHoverModeActive, + hoveredSeriesNames: this.hoveredSeriesNames, }) } diff --git a/packages/@ourworldindata/types/src/dbTypes/ChartViews.ts b/packages/@ourworldindata/types/src/dbTypes/ChartViews.ts index 5a5ad5d92be..4a0ec026a3b 100644 --- a/packages/@ourworldindata/types/src/dbTypes/ChartViews.ts +++ b/packages/@ourworldindata/types/src/dbTypes/ChartViews.ts @@ -29,6 +29,9 @@ export const CHART_VIEW_PROPS_TO_PERSIST: (keyof GrapherInterface)[] = [ // Time selection "minTime", "maxTime", + + // Focus state + "focusedSeriesNames", ] export const CHART_VIEW_PROPS_TO_OMIT: (keyof GrapherInterface)[] = [ diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index c1b01085b37..c10f3308a6c 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -585,6 +585,7 @@ export interface GrapherInterface extends SortConfig { includedEntities?: number[] selectedEntityNames?: EntityName[] selectedEntityColors?: { [entityName: string]: string | undefined } + focusedSeriesNames?: SeriesName[] missingDataStrategy?: MissingDataStrategy hideFacetControl?: boolean facettingLabelByYVariables?: string @@ -611,6 +612,7 @@ export interface LegacyGrapherInterface extends GrapherInterface { // See https://stackoverflow.com/q/64970414 export type GrapherQueryParams = { country?: string + focus?: string tab?: string overlay?: string stackMode?: string @@ -634,6 +636,7 @@ export type LegacyGrapherQueryParams = GrapherQueryParams & { // ... so GRAPHER_QUERY_PARAM_KEYS below is guaranteed to have all keys of LegacyGrapherQueryParams const GRAPHER_ALL_QUERY_PARAMS: Required = { country: "", + focus: "", tab: "", overlay: "", stackMode: "", @@ -704,6 +707,7 @@ export const grapherKeysToSerialize = [ "dimensions", "selectedEntityNames", "selectedEntityColors", + "focusedSeriesNames", "sortBy", "sortOrder", "sortColumnSlug", diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 26a65f6d148..9908fd28753 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -2045,3 +2045,16 @@ export function getPaginationPageNumbers( return pageNumbers } + +/** + * Checks for content equality, but doesn't care about the order of elements. + * + * For example, `isArrayDifferentFromReference([1, 2], [2, 1])` returns `false`. + */ +export function isArrayDifferentFromReference( + array: T[], + referenceArray: T[] +): boolean { + if (array.length !== referenceArray.length) return true + return difference(array, referenceArray).length > 0 +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index d3a9d6e1912..520058b256e 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -127,6 +127,7 @@ export { formatInlineList, lazy, getParentVariableIdFromChartConfig, + isArrayDifferentFromReference, } from "./Util.js" export {