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 {