Skip to content

Commit

Permalink
🎉 (line+slope) add focus state / TAS-739 (#4272)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
sophiamersmann authored Dec 17, 2024
1 parent f4a8e4c commit 2c1bc83
Show file tree
Hide file tree
Showing 35 changed files with 1,010 additions and 303 deletions.
20 changes: 20 additions & 0 deletions adminSiteClient/AbstractChartEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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[]

Expand Down
11 changes: 3 additions & 8 deletions adminSiteClient/ChartEditorTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,8 @@ export type FieldWithDetailReferences =
| "axisLabelX"
| "axisLabelY"

export interface DimensionErrorMessage {
displayName?: string
}
type ErrorMessageFieldName = FieldWithDetailReferences | "focusedSeriesNames"

export type ErrorMessages = Partial<Record<FieldWithDetailReferences, string>>
export type ErrorMessages = Partial<Record<ErrorMessageFieldName, string>>

export type ErrorMessagesForDimensions = Record<
DimensionProperty,
DimensionErrorMessage[]
>
export type ErrorMessagesForDimensions = Record<DimensionProperty, string[]>
13 changes: 10 additions & 3 deletions adminSiteClient/ChartEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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}`
}
})
})
Expand Down
5 changes: 2 additions & 3 deletions adminSiteClient/DimensionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,7 +34,7 @@ export class DimensionCard<
onChange: (dimension: ChartDimension) => void
onEdit?: () => void
onRemove?: () => void
errorMessage?: DimensionErrorMessage
errorMessage?: string
}> {
@observable.ref isExpanded: boolean = false

Expand Down Expand Up @@ -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}
/>
<BindAutoString
label="Unit of measurement"
Expand Down
16 changes: 13 additions & 3 deletions adminSiteClient/EditorBasicTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ class DimensionSlotView<

@observable.ref isSelectingVariables: boolean = false

private get editor() {
return this.props.editor
}

private get grapher() {
return this.props.editor.grapher
}
Expand Down Expand Up @@ -105,7 +109,7 @@ class DimensionSlotView<
this.updateParentConfig()
}

@action.bound private updateDefaults() {
@action.bound private updateDefaultSelection() {
const { grapher } = this.props.editor
const { selection } = grapher
const { availableEntityNames, availableEntityNameSet } = selection
Expand Down Expand Up @@ -151,11 +155,17 @@ class DimensionSlotView<
this.disposers.push(
reaction(
() => this.grapher.validChartTypes,
this.updateDefaults
() => {
this.updateDefaultSelection()
this.editor.removeInvalidFocusedSeriesNames()
}
),
reaction(
() => this.grapher.yColumnsFromDimensions.length,
this.updateDefaults
() => {
this.updateDefaultSelection()
this.editor.removeInvalidFocusedSeriesNames()
}
)
)
}
Expand Down
162 changes: 155 additions & 7 deletions adminSiteClient/EditorDataTab.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -24,14 +31,20 @@ import {
} from "react-beautiful-dnd"
import { AbstractChartEditor } from "./AbstractChartEditor.js"

interface EntityItemProps extends React.HTMLProps<HTMLDivElement> {
interface EntityListItemProps extends React.HTMLProps<HTMLDivElement> {
grapher: Grapher
entityName: EntityName
onRemove?: () => void
}

interface SeriesListItemProps extends React.HTMLProps<HTMLDivElement> {
seriesName: SeriesName
isValid?: boolean
onRemove?: () => void
}

@observer
class EntityItem extends React.Component<EntityItemProps> {
class EntityListItem extends React.Component<EntityListItemProps> {
@observable.ref isChoosingColor: boolean = false

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

@observer
export class KeysSection extends React.Component<{
class SeriesListItem extends React.Component<SeriesListItemProps> {
@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 (
<div className={className} key={seriesName} {...rest}>
<div>
{seriesName} {annotation}
</div>
<div className="clickable" onClick={this.onRemove}>
<FontAwesomeIcon icon={faTimes} />
</div>
</div>
)
}
}

@observer
export class EntitySelectionSection extends React.Component<{
editor: AbstractChartEditor
}> {
@observable.ref dragKey?: EntityName
Expand All @@ -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) {
Expand All @@ -122,6 +170,7 @@ export class KeysSection extends React.Component<{
grapher.selection.setSelectedEntities(
activeParentConfig.selectedEntityNames
)
this.editor.removeInvalidFocusedSeriesNames()
}

render() {
Expand Down Expand Up @@ -183,12 +232,12 @@ export class KeysSection extends React.Component<{
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<EntityItem
<EntityListItem
key={entityName}
grapher={grapher}
entityName={entityName}
onRemove={() =>
selection.deselectEntity(
this.onRemoveKey(
entityName
)
}
Expand Down Expand Up @@ -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 (
<Section name="Data to highlight">
<FieldsRow>
<SelectField
onValue={this.addToFocusedSeries}
value="Select data"
options={["Select data"]
.concat(availableSeriesNames)
.map((key) => ({ value: key }))}
/>
{editor.couldPropertyBeInherited("focusedSeriesNames") && (
<button
className="btn btn-outline-secondary"
type="button"
style={{ maxWidth: "min-content" }}
title="Reset to parent focus"
onClick={this.setFocusedSeriesNamesToParentValue}
disabled={isFocusInherited}
>
<FontAwesomeIcon
icon={isFocusInherited ? faLink : faUnlink}
/>
</button>
)}
</FieldsRow>
{focusedSeriesNames.map((seriesName) => (
<SeriesListItem
key={seriesName}
seriesName={seriesName}
isValid={!invalidFocusedSeriesNames.has(seriesName)}
onRemove={() =>
this.removeFromFocusedSeries(seriesName)
}
/>
))}
</Section>
)
}
}

@observer
class MissingDataSection<
Editor extends AbstractChartEditor,
Expand Down Expand Up @@ -331,7 +476,10 @@ export class EditorDataTab<
</label>
</div>
</Section>
<KeysSection editor={editor} />
<EntitySelectionSection editor={editor} />
{features.canHighlightSeries && (
<FocusSection editor={editor} />
)}
{features.canSpecifyMissingDataStrategy && (
<MissingDataSection editor={this.props.editor} />
)}
Expand Down
1 change: 1 addition & 0 deletions adminSiteClient/EditorExportTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class EditorExportTab<
staticFormat: format,
selectedEntityNames:
this.grapher.selection.selectedEntityNames,
focusedSeriesNames: this.grapher.focusedSeriesNames,
isSocialMediaExport,
})
}
Expand Down
7 changes: 7 additions & 0 deletions adminSiteClient/EditorFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,11 @@ export class EditorFeatures {
)
)
}

@computed get canHighlightSeries() {
return (
(this.grapher.hasLineChart || this.grapher.hasSlopeChart) &&
this.grapher.isOnChartTab
)
}
}
Loading

0 comments on commit 2c1bc83

Please sign in to comment.