From 1921177e7740927e4bc5d79f7ba290d9f10d71ad Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Mon, 9 Dec 2024 14:35:56 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(line+slope)=20add=20focus=20sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/chart/ChartManager.ts | 3 + .../grapher/src/chart/ChartUtils.tsx | 26 ++-- .../grapher/src/core/EntityUrlBuilder.ts | 6 +- .../grapher/src/core/Grapher.tsx | 49 +++++++- .../grapher/src/core/GrapherUrl.ts | 8 ++ .../src/entitySelector/EntitySelector.tsx | 7 +- .../grapher/src/facetChart/FacetChart.tsx | 12 ++ .../grapher/src/lineCharts/LineChart.tsx | 112 +++++++++++------- .../src/lineCharts/LineChartConstants.ts | 1 + .../grapher/src/lineLegend/LineLegend.tsx | 22 +++- .../src/schema/grapher-schema.006.yaml | 8 ++ .../grapher/src/selection/InteractionArray.ts | 11 ++ .../grapher/src/selection/SelectionArray.ts | 4 + .../grapher/src/slopeCharts/SlopeChart.tsx | 40 ++++--- .../src/slopeCharts/SlopeChartConstants.ts | 1 + .../types/src/grapherTypes/GrapherTypes.ts | 4 + 16 files changed, 239 insertions(+), 75 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts index d7036461c51..2db7d416d1b 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts @@ -20,6 +20,7 @@ import { SelectionArray } from "../selection/SelectionArray" import { ColumnSlug, SortConfig, TimeBound } from "@ourworldindata/utils" import { ColorScale } from "../color/ColorScale" import { ColorScaleBin } from "../color/ColorScaleBin" +import { InteractionArray } from "../selection/InteractionArray" // The possible options common across our chart types. Not all of these apply to every chart type, so there is room to create a better type hierarchy. @@ -97,4 +98,6 @@ export interface ChartManager { detailsOrderedByReference?: string[] detailsMarkerInSvg?: DetailsMarker + + focusArray?: InteractionArray } diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 859d51b1a5c..79b0a14dc88 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -193,15 +193,6 @@ export function findValidChartTypeCombination( return undefined } -/** Useful for sorting series by their interaction state */ -export function byInteractionState(series: { - hover: InteractionState -}): number { - if (series.hover.background) return 1 - if (series.hover.active) return 3 - return 2 -} - export function findSeriesNamesContainedInBin( series: readonly ChartSeries[], bin: ColorScaleBin @@ -210,3 +201,20 @@ export function findSeriesNamesContainedInBin( .map(({ seriesName }) => seriesName) .filter((seriesName) => bin.contains(seriesName)) } + +/** Useful for sorting series by their interaction state */ +export function byInteractionState(state: InteractionState): number { + if (state.background) return 1 + if (state.active) return 3 + return 2 +} + +/** Useful for sorting series by their interaction state */ +export function byHoverThenFocusState(series: { + hover: InteractionState + focus: InteractionState +}): number { + const hoverScore = byInteractionState(series.hover) + const focusScore = byInteractionState(series.focus) + return hoverScore * 10 + focusScore +} diff --git a/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts b/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts index 2e30e928629..1eac8c0f245 100644 --- a/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts +++ b/packages/@ourworldindata/grapher/src/core/EntityUrlBuilder.ts @@ -143,13 +143,17 @@ export const migrateSelectedEntityNamesParam: UrlMigration = ( * Accessors */ +export const getSelectedEntityNamesFromQueryParam = ( + queryParam: string +): EntityName[] => entityNamesFromV2Param(queryParam).map(codeToEntityName) + export const getSelectedEntityNamesParam = ( url: Url ): EntityName[] | undefined => { // Expects an already-migrated URL as input const { country } = url.queryParams return country !== undefined - ? entityNamesFromV2Param(country).map(codeToEntityName) + ? getSelectedEntityNamesFromQueryParam(country) : undefined } diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 1169f7d0e1b..0151ec85589 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -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 { + getSelectedEntityNamesParam, + getSelectedEntityNamesFromQueryParam, +} from "./EntityUrlBuilder" import { AxisConfig, AxisManager } from "../axis/AxisConfig" import { ColorScaleConfig } from "../color/ColorScaleConfig" import { MapConfig } from "../mapCharts/MapConfig" @@ -440,6 +444,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? @@ -566,6 +571,7 @@ export class Grapher ) obj.selectedEntityNames = this.selection.selectedEntityNames + obj.focusedSeriesNames = this.focusArray.activeSeriesNames deleteRuntimeAndUnchangedProps(obj, defaultObject) @@ -607,6 +613,9 @@ export class Grapher if (obj.selectedEntityNames) this.selection.setSelectedEntities(obj.selectedEntityNames) + if (obj.focusedSeriesNames) + this.focusArray.clearAllAndActivate(...obj.focusedSeriesNames) + // JSON doesn't support Infinity, so we use strings instead. this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) @@ -683,6 +692,14 @@ export class Grapher if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) this.selection.setSelectedEntities(selection) + const focusedSeriesNames = + params.focus !== undefined + ? getSelectedEntityNamesFromQueryParam(params.focus) + : undefined + if (focusedSeriesNames) { + this.focusArray.clearAllAndActivate(...focusedSeriesNames) + } + // faceting if (params.facet && params.facet in FacetStrategy) { this.selectedFacetStrategy = params.facet as FacetStrategy @@ -2538,6 +2555,8 @@ export class Grapher this.props.table?.availableEntities ?? [] ) + focusArray = new InteractionArray() + @computed get availableEntities(): Entity[] { return this.tableForSelection.availableEntities } @@ -3181,6 +3200,10 @@ export class Grapher ) } } + ), + reaction( + () => this.facetStrategy, + () => this.focusArray.clear() ) ) if (this.props.bindUrlToWindow) this.bindToWindow() @@ -3385,6 +3408,30 @@ export class Grapher return undefined } + @computed get focusedSeriesNamesIfDifferentThanAuthors(): + | SeriesName[] + | undefined { + const authoredConfig = this.legacyConfigAsAuthored + + const originalFocusedSeriesNames = + authoredConfig.focusedSeriesNames ?? [] + const currentFocusedSeriesNames = this.focusArray.activeSeriesNames + + const seriesNamesThatTheUserDeselected = difference( + currentFocusedSeriesNames, + originalFocusedSeriesNames + ) + + if ( + currentFocusedSeriesNames.length !== + originalFocusedSeriesNames.length || + seriesNamesThatTheUserDeselected.length + ) + return this.focusArray.activeSeriesNames + + return undefined + } + // Autocomputed url params to reflect difference between current grapher state // and original config state @computed.struct get changedParams(): Partial { diff --git a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts index fcbc1d8cdeb..3d4eb85d04a 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts @@ -53,6 +53,9 @@ export const grapherConfigToQueryParams = ( country: config.selectedEntityNames ? generateSelectedEntityNamesParam(config.selectedEntityNames) : undefined, + focus: config.focusedSeriesNames + ? generateSelectedEntityNamesParam(config.focusedSeriesNames) + : undefined, // These cannot be specified in config, so we always set them to undefined showSelectionOnlyInTable: undefined, @@ -97,6 +100,11 @@ export const grapherObjectToQueryParams = ( grapher.selectedEntitiesIfDifferentThanAuthors ) : undefined, + focus: grapher.focusedSeriesNamesIfDifferentThanAuthors + ? generateSelectedEntityNamesParam( + grapher.focusedSeriesNamesIfDifferentThanAuthors + ) + : undefined, } return params } diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index a6590dcf0be..ba0afd898e2 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 { InteractionArray } from "../selection/InteractionArray" export interface EntitySelectorState { searchInput: string @@ -70,6 +71,7 @@ export interface EntitySelectorManager { isEntitySelectorModalOrDrawerOpen?: boolean canChangeEntity?: boolean canHighlightEntities?: boolean + focusArray?: InteractionArray } interface SortConfig { @@ -603,7 +605,10 @@ export class EntitySelector extends React.Component<{ this.selectionArray.setSelectedEntities([entityName]) } - this.clearSearchInput() + // remove focus from an entity that has been removed from the selection + if (!this.selectionArray.isSelected(entityName)) { + this.manager.focusArray?.deactivate(entityName) + } // close the modal or drawer automatically after selection if in single mode if ( diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 4dcd9151dfc..e9b0c044fe8 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -480,6 +480,7 @@ export class FacetChart const manager = { ...series.manager, useValueBasedColorScheme, + focusArray: this.manager.focusArray, externalLegendHoverBin: this.legendHoverBin, xAxisConfig: { // For now, sharing an x axis means hiding the tick labels of inner facets. @@ -775,6 +776,17 @@ export class FacetChart this.legendHoverBin = undefined } + @action.bound onLegendClick(bin: ColorScaleBin): void { + const seriesNames = uniq( + this.intermediateChartInstances.flatMap((chartInstance) => + chartInstance.series.map((s) => s.seriesName) + ) + ) + this.manager.focusArray?.toggle( + ...seriesNames.filter((seriesName) => bin.contains(seriesName)) + ) + } + // end of legend props @computed private get legend(): HorizontalColorLegend { diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 6d6d72dcd7d..f03ee1eccfe 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -79,8 +79,8 @@ import { import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, - byInteractionState, findSeriesNamesContainedInBin, + byHoverThenFocusState, getDefaultFailMessage, getSeriesKey, isTargetOutsideElement, @@ -117,7 +117,6 @@ import { InteractionArray } from "../selection/InteractionArray" const LINE_CHART_CLASS_NAME = "LineChart" // line color -const BLUR_LINE_COLOR = "#eee" const DEFAULT_LINE_COLOR = "#000" // stroke width const DEFAULT_STROKE_WIDTH = 1.5 @@ -325,10 +324,6 @@ export class LineChart return makeSelectionArray(this.manager.selection) } - seriesIsBlurred(series: LineChartSeries): boolean { - return this.hoverArray.isInBackground(series.seriesName) - } - @computed get activeX(): number | undefined { return ( this.tooltipState.target?.x ?? @@ -351,11 +346,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 seriesColor = this.hasColorScale + ? darkenColorForLine( + this.getColorScaleColor(value.colorValue) + ) + : series.color + const color = + series.focus.background && !series.hover.active + ? BACKGROUND_DOT_COLOR + : seriesColor return ( )} { return { ...series, hover: hoverArray.state(series.seriesName), + focus: focusArray.state(series.seriesName), } } ) - if (this.hoverArray.hasActiveSeries) { - return sortBy(series, byInteractionState) + if ( + this.hoverArray.hasActiveSeries || + this.focusArray.hasActiveSeries + ) { + return sortBy(series, byHoverThenFocusState) } return series @@ -1143,6 +1155,7 @@ export class LineChart ), yValue: lastValue, hover: this.hoverArray.state(series.seriesName), + focus: this.focusArray.state(series.seriesName), } }) } @@ -1317,17 +1330,24 @@ class Lines extends React.Component { } private seriesHasMarkers(series: RenderLineChartSeries): boolean { - return !series.hover.background + return ( + series.hover.active || + (!series.focus.background && !series.hover.background) + ) } renderLine(series: RenderLineChartSeries): React.ReactElement { - const { hover } = series + const { hover, focus } = series - const color = series.placedPoints[0]?.color ?? DEFAULT_LINE_COLOR + const seriesColor = 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 ? 0.3 : 1 + const color = + !focus.background || hover.active + ? seriesColor + : BACKGROUND_LINE_COLOR + const strokeWidth = this.strokeWidth + const strokeOpacity = hover.background && !focus.background ? 0.3 : 1 const outline = this.renderPathForSeries(series, { id: makeIdForHumanConsumption("outline", series.seriesName), @@ -1380,26 +1400,34 @@ class Lines extends React.Component { if (hideMarkers && !forceMarkers) return const { horizontalAxis } = this.props.dualAxis - const opacity = series.hover.background ? 0.3 : 1 + const { focus, hover } = series + + const opacity = hover.background ? 0.3 : 1 return ( - {series.placedPoints.map((value, index) => ( - - ))} + {series.placedPoints.map((value, index) => { + const color = + !focus.background || hover.active + ? value.color + : BACKGROUND_LINE_COLOR + return ( + + ) + })} ) } 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/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index d6a30b9e4c6..13b86e6a729 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -53,6 +53,7 @@ export interface LineLabelSeries extends ChartSeries { placeFormattedValueInNewLine?: boolean yRange?: [number, number] hover?: InteractionState + focus?: InteractionState } interface SizedSeries extends LineLabelSeries { @@ -100,6 +101,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 @@ -117,7 +119,9 @@ class LineLabels extends React.Component<{ } private textOpacityForSeries(series: PlacedSeries): number { - return series.hover?.background ? 0.6 : 1 + const nonHovered = series.hover?.background + const nonFocused = series.focus?.background + return nonHovered && !nonFocused ? 0.6 : 1 } @computed private get markers(): { @@ -153,7 +157,12 @@ class LineLabels extends React.Component<{ return ( {this.markers.map(({ series, labelText }) => { - const textColor = darkenColorForText(series.color) + const hovered = series.hover?.active + const nonFocused = series.focus?.background + const textColor = + nonFocused && !hovered + ? BACKGROUND_TEXT_COLOR + : darkenColorForText(series.color) return ( this.props.onClick?.(series)} - style={{ cursor: "default" }} + style={{ cursor: this.props.cursor }} > { 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..51c256b218c 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 to line and slope charts for now. + items: + type: + - string baseColorScheme: type: string description: | diff --git a/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts b/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts index 16829aef32c..0ce6f730d70 100644 --- a/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts +++ b/packages/@ourworldindata/grapher/src/selection/InteractionArray.ts @@ -91,6 +91,17 @@ export class InteractionArray { return this } + @action.bound toggle(...seriesNames: SeriesName[]): this { + for (const seriesName of seriesNames) { + if (this.isActive(seriesName)) { + this.deactivate(seriesName) + } else { + this.activate(seriesName) + } + } + return this + } + @action.bound clear(): void { this.activeSet.clear() } diff --git a/packages/@ourworldindata/grapher/src/selection/SelectionArray.ts b/packages/@ourworldindata/grapher/src/selection/SelectionArray.ts index 8f1c06a0133..000716c408d 100644 --- a/packages/@ourworldindata/grapher/src/selection/SelectionArray.ts +++ b/packages/@ourworldindata/grapher/src/selection/SelectionArray.ts @@ -44,6 +44,10 @@ export class SelectionArray { return new Set(this.selectedEntityNames) } + isSelected(entityName: EntityName): boolean { + return this.selectedSet.has(entityName) + } + // Clears and sets selected entities @action.bound setSelectedEntities(entityNames: EntityName[]): this { this.clearSelection() diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 010f22bc842..4c488b13039 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -45,8 +45,8 @@ import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, - byInteractionState, findSeriesNamesContainedInBin, + byHoverThenFocusState, getDefaultFailMessage, getShortNameForEntity, makeSelectionArray, @@ -442,20 +442,28 @@ export class SlopeChart }) } + @computed get focusArray(): InteractionArray { + return this.manager.focusArray ?? new InteractionArray() + } + @computed private get renderSeries(): RenderSlopeChartSeries[] { - const { hoverArray } = this + const { hoverArray, focusArray } = this const series: RenderSlopeChartSeries[] = this.placedSeries.map( (series) => { return { ...series, hover: hoverArray.state(series.seriesName), + focus: focusArray.state(series.seriesName), } } ) - if (this.hoverArray.hasActiveSeries) { - return sortBy(series, byInteractionState) + if ( + this.hoverArray.hasActiveSeries || + this.focusArray.hasActiveSeries + ) { + return sortBy(series, byHoverThenFocusState) } return series @@ -605,6 +613,7 @@ export class SlopeChart textOutlineColor: this.backgroundColor, onMouseOver: this.onLineLegendMouseOver, onMouseLeave: this.onLineLegendMouseLeave, + onClick: this.onLineLegendClick, } } @@ -790,6 +799,7 @@ export class SlopeChart placeFormattedValueInNewLine: this.useCompactLayout, yValue: value, hover: this.hoverArray.state(seriesName), + focus: this.focusArray.state(seriesName), } } @@ -926,6 +936,10 @@ export class SlopeChart }, 200) } + @action.bound onLineLegendClick(seriesName: SeriesName): void { + this.focusArray.toggle(seriesName) + } + @action.bound onSlopeMouseOver(series: SlopeChartSeries): void { this.hoverArray.clearAllAndActivate(series.seriesName) this.tooltipState.target = { series } @@ -1104,7 +1118,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 { seriesName, startPoint, endPoint, hover, focus } = series - const isInBackground = series.hover.background - const showOutline = !isInBackground - const opacity = isInBackground ? 0.3 : 1 + const showOutline = !hover.background + const opacity = hover.background && !focus.background ? 0.3 : 1 + const color = + !focus.background || hover.active ? series.color : BACKGROUND_LINE_COLOR return ( onMouseOver?.(series)} - onMouseLeave={() => onMouseLeave?.()} > {showOutline && ( = { country: "", + focus: "", tab: "", overlay: "", stackMode: "", @@ -704,6 +707,7 @@ export const grapherKeysToSerialize = [ "dimensions", "selectedEntityNames", "selectedEntityColors", + "focusedSeriesNames", "sortBy", "sortOrder", "sortColumnSlug",