Skip to content

Commit 2c1bc83

Browse files
🎉 (line+slope) add focus state / TAS-739 (#4272)
Adds the option to highlight lines in line and slope charts ### Summary - Adds a new config field, `focusedSeriesNames` (suggestions for a different name welcome) - A line is identified by its series name which is either an entity name, a column display name or a combination of both - The list of focused series names is persisted to the URL as `focus` query param - The entity name utility functions are used to parse and serialise focused series names, so that the same delimiter is used and entity names are mapped to their codes if possible - This breaks if a column name contains `~` (the delimiter) which theoretically is possible but I don't think we need to worry about that now - Focused lines have bold labels, non-focused lines are grayed out - Grapher makes an effort to prevent the chart to enter a 'bad state' where all lines are grayed out because the focused line doesn't exist - This includes removing all elements from the focus array when the facet strategy changes and dismissing focused entities when they're unselected #### In the admin - There is a new 'Data to highlight' section below the entity selection section - If the chart is in a bad state because one of the focused series names is invalid, saving is disabled and shows an error message ### Follow up - It's a bit ugly that `selectedEntityNames` and `focusedSeriesNames` are always serialised, even for an empty Grapher. I've fixes that in a [follow-up PR](#4294) - The line legend method that drops labels if there are to many is a bit difficult to read. I'll open another PR with a refactor
1 parent f4a8e4c commit 2c1bc83

35 files changed

+1010
-303
lines changed

adminSiteClient/AbstractChartEditor.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
diffGrapherConfigs,
66
mergeGrapherConfigs,
77
PostReference,
8+
SeriesName,
9+
difference,
810
} from "@ourworldindata/utils"
911
import { action, computed, observable, when } from "mobx"
1012
import { EditorFeatures } from "./EditorFeatures.js"
@@ -163,6 +165,24 @@ export abstract class AbstractChartEditor<
163165
return Object.hasOwn(this.activeParentConfig, property)
164166
}
165167

168+
@computed get invalidFocusedSeriesNames(): SeriesName[] {
169+
const { grapher } = this
170+
171+
// if focusing is not supported, then all focused series are invalid
172+
if (!this.features.canHighlightSeries) {
173+
return grapher.focusArray.seriesNames
174+
}
175+
176+
// find invalid focused series
177+
const availableSeriesNames = grapher.chartSeriesNames
178+
const focusedSeriesNames = grapher.focusArray.seriesNames
179+
return difference(focusedSeriesNames, availableSeriesNames)
180+
}
181+
182+
@action.bound removeInvalidFocusedSeriesNames(): void {
183+
this.grapher.focusArray.remove(...this.invalidFocusedSeriesNames)
184+
}
185+
166186
abstract get isNewGrapher(): boolean
167187
abstract get availableTabs(): EditorTab[]
168188

adminSiteClient/ChartEditorTypes.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,8 @@ export type FieldWithDetailReferences =
66
| "axisLabelX"
77
| "axisLabelY"
88

9-
export interface DimensionErrorMessage {
10-
displayName?: string
11-
}
9+
type ErrorMessageFieldName = FieldWithDetailReferences | "focusedSeriesNames"
1210

13-
export type ErrorMessages = Partial<Record<FieldWithDetailReferences, string>>
11+
export type ErrorMessages = Partial<Record<ErrorMessageFieldName, string>>
1412

15-
export type ErrorMessagesForDimensions = Record<
16-
DimensionProperty,
17-
DimensionErrorMessage[]
18-
>
13+
export type ErrorMessagesForDimensions = Record<DimensionProperty, string[]>

adminSiteClient/ChartEditorView.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,14 @@ export class ChartEditorView<
265265
}
266266
)
267267

268+
// add an error message if any focused series names are invalid
269+
const { invalidFocusedSeriesNames = [] } = this.editor ?? {}
270+
if (invalidFocusedSeriesNames.length > 0) {
271+
const invalidNames = invalidFocusedSeriesNames.join(", ")
272+
const message = `Invalid focus state. The following entities/indicators are not plotted: ${invalidNames}`
273+
errorMessages.focusedSeriesNames = message
274+
}
275+
268276
return errorMessages
269277
}
270278

@@ -287,9 +295,8 @@ export class ChartEditorView<
287295

288296
// add error message if details are referenced in the display name
289297
if (hasDetailsInDisplayName) {
290-
errorMessages[slot.property][dimensionIndex] = {
291-
displayName: "Detail syntax is not supported",
292-
}
298+
errorMessages[slot.property][dimensionIndex] =
299+
`Detail syntax is not supported for display names of indicators: ${dimension.display.name}`
293300
}
294301
})
295302
})

adminSiteClient/DimensionCard.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { observer } from "mobx-react"
44
import { ChartDimension } from "@ourworldindata/grapher"
55
import { OwidColumnDef, OwidVariableRoundingMode } from "@ourworldindata/types"
66
import { startCase } from "@ourworldindata/utils"
7-
import { DimensionErrorMessage } from "./ChartEditorTypes.js"
87
import {
98
Toggle,
109
BindAutoString,
@@ -35,7 +34,7 @@ export class DimensionCard<
3534
onChange: (dimension: ChartDimension) => void
3635
onEdit?: () => void
3736
onRemove?: () => void
38-
errorMessage?: DimensionErrorMessage
37+
errorMessage?: string
3938
}> {
4039
@observable.ref isExpanded: boolean = false
4140

@@ -171,7 +170,7 @@ export class DimensionCard<
171170
store={dimension.display}
172171
auto={column.displayName}
173172
onBlur={this.onChange}
174-
errorMessage={this.props.errorMessage?.displayName}
173+
errorMessage={this.props.errorMessage}
175174
/>
176175
<BindAutoString
177176
label="Unit of measurement"

adminSiteClient/EditorBasicTab.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ class DimensionSlotView<
6161

6262
@observable.ref isSelectingVariables: boolean = false
6363

64+
private get editor() {
65+
return this.props.editor
66+
}
67+
6468
private get grapher() {
6569
return this.props.editor.grapher
6670
}
@@ -105,7 +109,7 @@ class DimensionSlotView<
105109
this.updateParentConfig()
106110
}
107111

108-
@action.bound private updateDefaults() {
112+
@action.bound private updateDefaultSelection() {
109113
const { grapher } = this.props.editor
110114
const { selection } = grapher
111115
const { availableEntityNames, availableEntityNameSet } = selection
@@ -151,11 +155,17 @@ class DimensionSlotView<
151155
this.disposers.push(
152156
reaction(
153157
() => this.grapher.validChartTypes,
154-
this.updateDefaults
158+
() => {
159+
this.updateDefaultSelection()
160+
this.editor.removeInvalidFocusedSeriesNames()
161+
}
155162
),
156163
reaction(
157164
() => this.grapher.yColumnsFromDimensions.length,
158-
this.updateDefaults
165+
() => {
166+
this.updateDefaultSelection()
167+
this.editor.removeInvalidFocusedSeriesNames()
168+
}
159169
)
160170
)
161171
}

adminSiteClient/EditorDataTab.tsx

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import React from "react"
2-
import { moveArrayItemToIndex, omit } from "@ourworldindata/utils"
2+
import {
3+
differenceOfSets,
4+
moveArrayItemToIndex,
5+
omit,
6+
sortBy,
7+
} from "@ourworldindata/utils"
38
import { computed, action, observable } from "mobx"
49
import { observer } from "mobx-react"
10+
import cx from "classnames"
511
import {
612
EntitySelectionMode,
713
MissingDataStrategy,
814
EntityName,
15+
SeriesName,
916
} from "@ourworldindata/types"
1017
import { Grapher } from "@ourworldindata/grapher"
1118
import { ColorBox, SelectField, Section, FieldsRow } from "./Forms.js"
@@ -24,14 +31,20 @@ import {
2431
} from "react-beautiful-dnd"
2532
import { AbstractChartEditor } from "./AbstractChartEditor.js"
2633

27-
interface EntityItemProps extends React.HTMLProps<HTMLDivElement> {
34+
interface EntityListItemProps extends React.HTMLProps<HTMLDivElement> {
2835
grapher: Grapher
2936
entityName: EntityName
3037
onRemove?: () => void
3138
}
3239

40+
interface SeriesListItemProps extends React.HTMLProps<HTMLDivElement> {
41+
seriesName: SeriesName
42+
isValid?: boolean
43+
onRemove?: () => void
44+
}
45+
3346
@observer
34-
class EntityItem extends React.Component<EntityItemProps> {
47+
class EntityListItem extends React.Component<EntityListItemProps> {
3548
@observable.ref isChoosingColor: boolean = false
3649

3750
@computed get table() {
@@ -89,7 +102,36 @@ class EntityItem extends React.Component<EntityItemProps> {
89102
}
90103

91104
@observer
92-
export class KeysSection extends React.Component<{
105+
class SeriesListItem extends React.Component<SeriesListItemProps> {
106+
@action.bound onRemove() {
107+
this.props.onRemove?.()
108+
}
109+
110+
render() {
111+
const { props } = this
112+
const { seriesName, isValid } = props
113+
const rest = omit(props, ["seriesName", "isValid", "onRemove"])
114+
115+
const className = cx("ListItem", "list-group-item", {
116+
invalid: !isValid,
117+
})
118+
const annotation = !isValid ? "(not plotted)" : ""
119+
120+
return (
121+
<div className={className} key={seriesName} {...rest}>
122+
<div>
123+
{seriesName} {annotation}
124+
</div>
125+
<div className="clickable" onClick={this.onRemove}>
126+
<FontAwesomeIcon icon={faTimes} />
127+
</div>
128+
</div>
129+
)
130+
}
131+
}
132+
133+
@observer
134+
export class EntitySelectionSection extends React.Component<{
93135
editor: AbstractChartEditor
94136
}> {
95137
@observable.ref dragKey?: EntityName
@@ -100,6 +142,12 @@ export class KeysSection extends React.Component<{
100142

101143
@action.bound onAddKey(entityName: EntityName) {
102144
this.editor.grapher.selection.selectEntity(entityName)
145+
this.editor.removeInvalidFocusedSeriesNames()
146+
}
147+
148+
@action.bound onRemoveKey(entityName: EntityName) {
149+
this.editor.grapher.selection.deselectEntity(entityName)
150+
this.editor.removeInvalidFocusedSeriesNames()
103151
}
104152

105153
@action.bound onDragEnd(result: DropResult) {
@@ -122,6 +170,7 @@ export class KeysSection extends React.Component<{
122170
grapher.selection.setSelectedEntities(
123171
activeParentConfig.selectedEntityNames
124172
)
173+
this.editor.removeInvalidFocusedSeriesNames()
125174
}
126175

127176
render() {
@@ -183,12 +232,12 @@ export class KeysSection extends React.Component<{
183232
{...provided.draggableProps}
184233
{...provided.dragHandleProps}
185234
>
186-
<EntityItem
235+
<EntityListItem
187236
key={entityName}
188237
grapher={grapher}
189238
entityName={entityName}
190239
onRemove={() =>
191-
selection.deselectEntity(
240+
this.onRemoveKey(
192241
entityName
193242
)
194243
}
@@ -216,6 +265,102 @@ export class KeysSection extends React.Component<{
216265
}
217266
}
218267

268+
@observer
269+
export class FocusSection extends React.Component<{
270+
editor: AbstractChartEditor
271+
}> {
272+
@computed get editor() {
273+
return this.props.editor
274+
}
275+
276+
@action.bound addToFocusedSeries(seriesName: SeriesName) {
277+
this.editor.grapher.focusArray.add(seriesName)
278+
}
279+
280+
@action.bound removeFromFocusedSeries(seriesName: SeriesName) {
281+
this.editor.grapher.focusArray.remove(seriesName)
282+
}
283+
284+
@action.bound setFocusedSeriesNamesToParentValue() {
285+
const { grapher, activeParentConfig } = this.editor
286+
if (!activeParentConfig || !activeParentConfig.focusedSeriesNames)
287+
return
288+
grapher.focusArray.clearAllAndAdd(
289+
...activeParentConfig.focusedSeriesNames
290+
)
291+
this.editor.removeInvalidFocusedSeriesNames()
292+
}
293+
294+
render() {
295+
const { editor } = this
296+
const { grapher } = editor
297+
298+
const isFocusInherited =
299+
editor.isPropertyInherited("focusedSeriesNames")
300+
301+
const focusedSeriesNameSet = grapher.focusArray.seriesNameSet
302+
const focusedSeriesNames = grapher.focusArray.seriesNames
303+
304+
// series available to highlight are those that are currently plotted
305+
const seriesNameSet = new Set(grapher.chartSeriesNames)
306+
const availableSeriesNameSet = differenceOfSets([
307+
seriesNameSet,
308+
focusedSeriesNameSet,
309+
])
310+
311+
// focusing only makes sense for two or more plotted series
312+
if (focusedSeriesNameSet.size === 0 && availableSeriesNameSet.size < 2)
313+
return null
314+
315+
const availableSeriesNames: SeriesName[] = sortBy(
316+
Array.from(availableSeriesNameSet)
317+
)
318+
319+
const invalidFocusedSeriesNames = differenceOfSets([
320+
focusedSeriesNameSet,
321+
seriesNameSet,
322+
])
323+
324+
return (
325+
<Section name="Data to highlight">
326+
<FieldsRow>
327+
<SelectField
328+
onValue={this.addToFocusedSeries}
329+
value="Select data"
330+
options={["Select data"]
331+
.concat(availableSeriesNames)
332+
.map((key) => ({ value: key }))}
333+
/>
334+
{editor.couldPropertyBeInherited("focusedSeriesNames") && (
335+
<button
336+
className="btn btn-outline-secondary"
337+
type="button"
338+
style={{ maxWidth: "min-content" }}
339+
title="Reset to parent focus"
340+
onClick={this.setFocusedSeriesNamesToParentValue}
341+
disabled={isFocusInherited}
342+
>
343+
<FontAwesomeIcon
344+
icon={isFocusInherited ? faLink : faUnlink}
345+
/>
346+
</button>
347+
)}
348+
</FieldsRow>
349+
{focusedSeriesNames.map((seriesName) => (
350+
<SeriesListItem
351+
key={seriesName}
352+
seriesName={seriesName}
353+
isValid={!invalidFocusedSeriesNames.has(seriesName)}
354+
onRemove={() =>
355+
this.removeFromFocusedSeries(seriesName)
356+
}
357+
/>
358+
))}
359+
</Section>
360+
)
361+
}
362+
}
363+
219364
@observer
220365
class MissingDataSection<
221366
Editor extends AbstractChartEditor,
@@ -331,7 +476,10 @@ export class EditorDataTab<
331476
</label>
332477
</div>
333478
</Section>
334-
<KeysSection editor={editor} />
479+
<EntitySelectionSection editor={editor} />
480+
{features.canHighlightSeries && (
481+
<FocusSection editor={editor} />
482+
)}
335483
{features.canSpecifyMissingDataStrategy && (
336484
<MissingDataSection editor={this.props.editor} />
337485
)}

adminSiteClient/EditorExportTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ export class EditorExportTab<
211211
staticFormat: format,
212212
selectedEntityNames:
213213
this.grapher.selection.selectedEntityNames,
214+
focusedSeriesNames: this.grapher.focusedSeriesNames,
214215
isSocialMediaExport,
215216
})
216217
}

adminSiteClient/EditorFeatures.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,11 @@ export class EditorFeatures {
156156
)
157157
)
158158
}
159+
160+
@computed get canHighlightSeries() {
161+
return (
162+
(this.grapher.hasLineChart || this.grapher.hasSlopeChart) &&
163+
this.grapher.isOnChartTab
164+
)
165+
}
159166
}

0 commit comments

Comments
 (0)