diff --git a/adminSiteClient/EditorBasicTab.tsx b/adminSiteClient/EditorBasicTab.tsx index 04f05a9cead..fa60a83d82d 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -109,7 +109,7 @@ class DimensionSlotView< const { selection } = grapher const { availableEntityNames, availableEntityNameSet } = selection - if (grapher.isScatter || grapher.isSlopeChart || grapher.isMarimekko) { + if (grapher.isScatter || grapher.isMarimekko) { // chart types that display all entities by default shouldn't select any by default selection.clearSelection() } else if ( @@ -372,8 +372,8 @@ export class EditorBasicTab< grapher.stackMode = StackMode.relative } - // Give scatterplots and slope charts a default color dimension if they don't have one - if (grapher.isScatter || grapher.isSlopeChart) { + // Give scatterplots a default color and size dimensions + if (grapher.isScatter) { const hasColor = grapher.dimensions.find( (d) => d.property === DimensionProperty.color ) @@ -382,10 +382,7 @@ export class EditorBasicTab< variableId: CONTINENTS_INDICATOR_ID, property: DimensionProperty.color, }) - } - // Give scatterplots a default size dimension if they don't have one - if (grapher.isScatter) { const hasSize = grapher.dimensions.find( (d) => d.property === DimensionProperty.size ) diff --git a/adminSiteClient/EditorFeatures.tsx b/adminSiteClient/EditorFeatures.tsx index 2d9761add1c..0fefaa9c693 100644 --- a/adminSiteClient/EditorFeatures.tsx +++ b/adminSiteClient/EditorFeatures.tsx @@ -62,6 +62,7 @@ export class EditorFeatures { @computed get hideLegend() { return ( this.grapher.isLineChart || + this.grapher.isSlopeChart || this.grapher.isStackedArea || this.grapher.isStackedDiscreteBar ) @@ -77,6 +78,7 @@ export class EditorFeatures { this.grapher.isStackedBar || this.grapher.isStackedDiscreteBar || this.grapher.isLineChart || + this.grapher.isSlopeChart || this.grapher.isScatter || this.grapher.isMarimekko ) @@ -118,9 +120,9 @@ export class EditorFeatures { return true } - // for line charts, specifying a missing data strategy only makes sense + // for line and slope charts, specifying a missing data strategy only makes sense // if there are multiple entities - if (this.grapher.isLineChart) { + if (this.grapher.isLineChart || this.grapher.isSlopeChart) { return ( this.grapher.canChangeEntity || this.grapher.canSelectMultipleEntities @@ -132,7 +134,7 @@ export class EditorFeatures { @computed get showChangeInPrefixToggle() { return ( - this.grapher.isLineChart && + (this.grapher.isLineChart || this.grapher.isSlopeChart) && (this.grapher.isRelativeMode || this.grapher.canToggleRelativeMode) ) } diff --git a/baker/updateChartEntities.ts b/baker/updateChartEntities.ts index 49e582a71d8..cbaf6a74107 100644 --- a/baker/updateChartEntities.ts +++ b/baker/updateChartEntities.ts @@ -106,7 +106,7 @@ const obtainAvailableEntitiesForGrapherConfig = async ( // In these chart types, an unselected entity is still shown const chartTypeShowsUnselectedEntities = - grapher.isScatter || grapher.isSlopeChart || grapher.isMarimekko + grapher.isScatter || grapher.isMarimekko if (canChangeEntities || chartTypeShowsUnselectedEntities) return grapher.tableForSelection.availableEntityNames as string[] diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 40da7cba3dc..390dfb88bcd 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -305,12 +305,22 @@ export abstract class AbstractCoreColumn { @imemo get displayName(): string { return ( this.display?.name ?? - this.def.presentation?.titlePublic ?? // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + this.def.presentation?.titlePublic ?? this.name ?? "" ) } + @imemo get nonEmptyDisplayName(): string { + return ( + this.display?.name || + // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + this.def.presentation?.titlePublic || + this.nonEmptyName + ) + } + @imemo get titlePublicOrDisplayName(): IndicatorTitleWithFragments { return this.def.presentation?.titlePublic ? { diff --git a/packages/@ourworldindata/core-table/src/OwidTable.ts b/packages/@ourworldindata/core-table/src/OwidTable.ts index 529aff908b1..234bdb733d4 100644 --- a/packages/@ourworldindata/core-table/src/OwidTable.ts +++ b/packages/@ourworldindata/core-table/src/OwidTable.ts @@ -325,6 +325,95 @@ export class OwidTable extends CoreTable { ) } + // Drop _all rows_ for an entity if all columns have at least one invalid or missing value for that entity. + dropEntitiesThatHaveSomeMissingOrErrorValueInAllColumns( + columnSlugs: ColumnSlug[] + ): this { + const indexesByEntityName = this.rowIndicesByEntityName + const uniqTimes = new Set(this.allTimes) + + // entity names to iterate over + const entityNamesToIterateOver = new Set(indexesByEntityName.keys()) + + // set of entities we want to keep + const entityNamesToKeep = new Set() + + // total number of entities + const entityCount = entityNamesToIterateOver.size + + // helper function to generate operation name + const makeOpName = (entityNamesToKeep: Set): string => { + const entityNamesToDrop = differenceOfSets([ + this.availableEntityNameSet, + entityNamesToKeep, + ]) + const droppedEntitiesStr = + entityNamesToDrop.size > 0 + ? [...entityNamesToDrop].join(", ") + : "(None)" + return `Drop entities that have some missing or error value in all column: ${columnSlugs.join(", ")}.\nDropped entities: ${droppedEntitiesStr}` + } + + // Optimization: if there is a column that has a valid data entry for + // every entity and every time, we are done + for (let i = 0; i <= columnSlugs.length; i++) { + const slug = columnSlugs[i] + const col = this.get(slug) + + if ( + col.numValues === entityCount * uniqTimes.size && + col.numErrorValues === 0 + ) { + const entityNamesToKeep = new Set(indexesByEntityName.keys()) + + return this.columnFilter( + this.entityNameSlug, + (rowEntityName) => + entityNamesToKeep.has(rowEntityName as string), + makeOpName(entityNamesToKeep) + ) + } + } + + for (let i = 0; i <= columnSlugs.length; i++) { + const slug = columnSlugs[i] + const col = this.get(slug) + + for (const entityName of entityNamesToIterateOver) { + const indicesForEntityName = indexesByEntityName.get(entityName) + if (!indicesForEntityName) + throw new Error("Unexpected: entity not found in index map") + + // Optimization: If the column is missing values for the entity, + // we know we can't make a decision yet, so we skip this entity + if (indicesForEntityName.length < uniqTimes.size) continue + + // Optimization: We don't care about the number of valid/error + // values, we just need to know if there is at least one invalid value + const hasSomeInvalidValueForEntityInCol = + indicesForEntityName.some( + (index) => + !isNotErrorValue( + col.valuesIncludingErrorValues[index] + ) + ) + + // Optimization: If all values are valid, we know we want to keep this entity, + // so we remove it from the entities to iterate over + if (!hasSomeInvalidValueForEntityInCol) { + entityNamesToKeep.add(entityName) + entityNamesToIterateOver.delete(entityName) + } + } + } + + return this.columnFilter( + this.entityNameSlug, + (rowEntityName) => entityNamesToKeep.has(rowEntityName as string), + makeOpName(entityNamesToKeep) + ) + } + private sumsByTime(columnSlug: ColumnSlug): Map { const timeValues = this.timeColumn.values const values = this.get(columnSlug).values as number[] diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 144ed286209..242ddc08248 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -409,11 +409,14 @@ export class DiscreteBarChart {this.placedSeries.map((series) => { return ( - series.label && - series.label.render( - series.entityLabelX, - series.barY - series.label.height / 2, - { textProps: style } + series.label && ( + + {series.label.render( + series.entityLabelX, + series.barY - series.label.height / 2, + { textProps: style } + )} + ) ) })} @@ -990,6 +993,7 @@ function makeProjectedDataPattern(color: string): React.ReactElement { const size = 7 return ( { if (manager.isOnTableTab) return undefined if (manager.isOnMapTab) return GRAPHER_MAP_TYPE if (manager.isOnChartTab) { - return manager.isLineChartThatTurnedIntoDiscreteBar + return manager.isLineChartThatTurnedIntoDiscreteBarActive ? GRAPHER_CHART_TYPES.DiscreteBar : manager.activeChartType } diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 375e9816c7a..501d646456d 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Box, getCountryByName } from "@ourworldindata/utils" +import { areSetsEqual, Box, getCountryByName } from "@ourworldindata/utils" import { SeriesStrategy, EntityName, @@ -15,6 +15,7 @@ import { GRAPHER_SIDE_PANEL_CLASS, GRAPHER_TIMELINE_CLASS, GRAPHER_SETTINGS_CLASS, + validChartTypeCombinations, } from "../core/GrapherConstants" export const autoDetectYColumnSlugs = (manager: ChartManager): string[] => { @@ -175,3 +176,15 @@ export function mapChartTypeNameToQueryParam( return GRAPHER_TAB_QUERY_PARAMS.marimekko } } + +export function findValidChartTypeCombination( + chartTypes: GrapherChartType[] +): GrapherChartType[] | undefined { + const chartTypeSet = new Set(chartTypes) + for (const validCombination of validChartTypeCombinations) { + const validCombinationSet = new Set(validCombination) + if (areSetsEqual(chartTypeSet, validCombinationSet)) + return validCombination + } + return undefined +} diff --git a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx index 6c0eaa289a5..848f4340514 100644 --- a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx +++ b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx @@ -18,6 +18,7 @@ export interface ContentSwitchersManager { activeTab?: GrapherTabName hasMultipleChartTypes?: boolean setTab: (tab: GrapherTabName) => void + onTabChange: (oldTab: GrapherTabName, newTab: GrapherTabName) => void isNarrow?: boolean isMedium?: boolean isLineChartThatTurnedIntoDiscreteBar?: boolean @@ -112,8 +113,10 @@ export class ContentSwitchers extends React.Component<{ } @action.bound setTab(tabIndex: number): void { + const oldTab = this.manager.activeTab const newTab = this.availableTabs[tabIndex] this.manager.setTab(newTab) + this.manager.onTabChange?.(oldTab!, newTab) } render(): React.ReactElement { @@ -175,9 +178,11 @@ function TabIcon({ case GRAPHER_TAB_NAMES.WorldMap: return default: - const chartIcon = isLineChartThatTurnedIntoDiscreteBar - ? chartIcons[GRAPHER_CHART_TYPES.DiscreteBar] - : chartIcons[tab] + const chartIcon = + tab === GRAPHER_TAB_NAMES.LineChart && + isLineChartThatTurnedIntoDiscreteBar + ? chartIcons[GRAPHER_CHART_TYPES.DiscreteBar] + : chartIcons[tab] return chartIcon } } @@ -193,9 +198,15 @@ function makeTabLabelText( if (tab === GRAPHER_TAB_NAMES.WorldMap) return "Map" if (!options.hasMultipleChartTypes) return "Chart" + if ( + tab === GRAPHER_TAB_NAMES.LineChart && + options.isLineChartThatTurnedIntoDiscreteBar + ) + return "Bar" + switch (tab) { case GRAPHER_TAB_NAMES.LineChart: - return options.isLineChartThatTurnedIntoDiscreteBar ? "Bar" : "Line" + return "Line" case GRAPHER_TAB_NAMES.SlopeChart: return "Slope" diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx index 30cbd370147..fb7f4351ce7 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx @@ -51,6 +51,7 @@ const { StackedDiscreteBar, StackedBar, Marimekko, + SlopeChart, } = GRAPHER_CHART_TYPES export interface SettingsMenuManager @@ -170,6 +171,7 @@ export class SettingsMenu extends React.Component<{ ScatterPlot, LineChart, Marimekko, + SlopeChart, ].includes(this.chartType as any) } diff --git a/packages/@ourworldindata/grapher/src/controls/settings/AbsRelToggle.tsx b/packages/@ourworldindata/grapher/src/controls/settings/AbsRelToggle.tsx index b90e825960b..d24ce59e359 100644 --- a/packages/@ourworldindata/grapher/src/controls/settings/AbsRelToggle.tsx +++ b/packages/@ourworldindata/grapher/src/controls/settings/AbsRelToggle.tsx @@ -8,7 +8,7 @@ import { } from "@ourworldindata/types" import { LabeledSwitch } from "@ourworldindata/components" -const { LineChart, ScatterPlot } = GRAPHER_CHART_TYPES +const { LineChart, ScatterPlot, SlopeChart } = GRAPHER_CHART_TYPES export interface AbsRelToggleManager { stackMode?: StackMode @@ -38,7 +38,7 @@ export class AbsRelToggle extends React.Component<{ const { activeChartType } = this.manager return activeChartType === ScatterPlot ? "Show the percentage change per year over the the selected time range." - : activeChartType === LineChart + : activeChartType === LineChart || activeChartType === SlopeChart ? "Show proportional changes over time or actual values in their original units." : "Show values as their share of the total or as actual values in their original units." } diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 07a0e552c02..673b56b3167 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -66,7 +66,6 @@ import { extractDetailsFromSyntax, omit, isTouchDevice, - areSetsEqual, } from "@ourworldindata/utils" import { MarkdownTextWrap, @@ -138,7 +137,6 @@ import { GRAPHER_FRAME_PADDING_HORIZONTAL, GRAPHER_FRAME_PADDING_VERTICAL, latestGrapherConfigSchema, - validChartTypeCombinations, GRAPHER_SQUARE_SIZE, } from "../core/GrapherConstants" import { loadVariableDataAndMetadata } from "./loadVariable" @@ -198,6 +196,7 @@ import { ScatterPlotManager } from "../scatterCharts/ScatterPlotChartConstants" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + findValidChartTypeCombination, mapChartTypeNameToQueryParam, mapQueryParamToChartTypeName, } from "../chart/ChartUtils" @@ -1310,6 +1309,26 @@ export class Grapher } } + @action.bound onTabChange( + oldTab: GrapherTabName, + newTab: GrapherTabName + ): void { + // if switching from a line to a slope chart and the handles are + // on the same time, then automatically adjust the handles so that + // the slope chart view is meaningful + if ( + oldTab === GRAPHER_TAB_NAMES.LineChart && + newTab === GRAPHER_TAB_NAMES.SlopeChart && + this.areHandlesOnSameTime + ) { + if (this.startHandleTimeBound !== -Infinity) { + this.startHandleTimeBound = -Infinity + } else { + this.endHandleTimeBound = Infinity + } + } + } + // todo: can we remove this? // I believe these states can only occur during editing. @action.bound private ensureValidConfigWhenEditing(): void { @@ -1408,7 +1427,6 @@ export class Grapher if (this.isLineChart || this.isDiscreteBar) return [yAxis, color] else if (this.isScatter) return [yAxis, xAxis, size, color] else if (this.isMarimekko) return [yAxis, xAxis, color] - else if (this.isSlopeChart) return [yAxis, color] return [yAxis] } @@ -1524,21 +1542,31 @@ export class Grapher }) } + @computed get hasProjectedData(): boolean { + return this.inputTable.numericColumnSlugs.some( + (slug) => this.inputTable.get(slug).isProjection + ) + } + @computed get validChartTypes(): GrapherChartType[] { const { chartTypes } = this // all single-chart Graphers are valid if (chartTypes.length <= 1) return chartTypes - const chartTypeSet = new Set(chartTypes) - for (const validCombination of validChartTypeCombinations) { - const validCombinationSet = new Set(validCombination) - if (areSetsEqual(chartTypeSet, validCombinationSet)) - return validCombination - } + // find valid combination in a pre-defined list + const validChartTypes = findValidChartTypeCombination(chartTypes) // if the given combination is not valid, then ignore all but the first chart type - return chartTypes.slice(0, 1) + if (!validChartTypes) return chartTypes.slice(0, 1) + + // projected data is only supported for line charts + const isLineChart = validChartTypes[0] === GRAPHER_CHART_TYPES.LineChart + if (isLineChart && this.hasProjectedData) { + return [GRAPHER_CHART_TYPES.LineChart] + } + + return validChartTypes } @computed get validChartTypeSet(): Set { @@ -1605,7 +1633,7 @@ export class Grapher !this.hideAnnotationFieldsInTitle?.changeInPrefix return ( !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && - this.isOnLineChartTab && + (this.isOnLineChartTab || this.isOnSlopeChartTab) && this.isRelativeMode && showChangeInPrefix ) @@ -1634,7 +1662,7 @@ export class Grapher if (this.shouldAddChangeInPrefixToTitle) text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) - if (this.shouldAddTimeSuffixToTitle) + if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) text = appendAnnotationField(text, this.timeTitleSuffix) return text.trim() @@ -1737,11 +1765,11 @@ export class Grapher return this.xAxis.scaleType } - @computed private get timeTitleSuffix(): string { + @computed private get timeTitleSuffix(): string | undefined { const timeColumn = this.table.timeColumn - if (timeColumn.isMissing) return "" // Do not show year until data is loaded + if (timeColumn.isMissing) return undefined // Do not show year until data is loaded const { startTime, endTime } = this - if (startTime === undefined || endTime === undefined) return "" + if (startTime === undefined || endTime === undefined) return undefined const time = startTime === endTime @@ -1933,7 +1961,7 @@ export class Grapher @computed get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { - return this.isLineChartThatTurnedIntoDiscreteBar + return this.isLineChartThatTurnedIntoDiscreteBarActive ? GRAPHER_CHART_TYPES.DiscreteBar : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) } @@ -1992,6 +2020,12 @@ export class Grapher return closestMinTime !== undefined && closestMinTime === closestMaxTime } + @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { + return ( + this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar + ) + } + @computed get isOnLineChartTab(): boolean { return this.activeChartType === GRAPHER_CHART_TYPES.LineChart } @@ -2025,7 +2059,7 @@ export class Grapher } @computed get supportsMultipleYColumns(): boolean { - return !(this.isScatter || this.isSlopeChart) + return !this.isScatter } @computed private get xDimension(): ChartDimension | undefined { @@ -2148,7 +2182,8 @@ export class Grapher @computed get relativeToggleLabel(): string { if (this.isOnScatterTab) return "Display average annual change" - else if (this.isOnLineChartTab) return "Display relative change" + else if (this.isOnLineChartTab || this.isOnSlopeChartTab) + return "Display relative change" return "Display relative values" } @@ -2168,6 +2203,7 @@ export class Grapher @computed get canToggleRelativeMode(): boolean { const { isOnLineChartTab, + isOnSlopeChartTab, hideRelativeToggle, areHandlesOnSameTime, yScaleType, @@ -2178,7 +2214,7 @@ export class Grapher isStackedChartSplitByMetric, } = this - if (isOnLineChartTab) + if (isOnLineChartTab || isOnSlopeChartTab) return ( !hideRelativeToggle && !areHandlesOnSameTime && @@ -3465,7 +3501,7 @@ export class Grapher } @computed get disablePlay(): boolean { - return this.isOnSlopeChartTab + return false } @computed get animationEndTime(): Time { @@ -3522,6 +3558,7 @@ export class Grapher this.hasChartTab && this.canSelectMultipleEntities && (this.isOnLineChartTab || + this.isOnSlopeChartTab || this.isOnStackedAreaTab || this.isOnStackedBarTab || this.isOnDiscreteBarTab || diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts index c825a821719..3655c9b83e3 100755 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.test.ts @@ -100,7 +100,7 @@ describe("series naming in multi-column mode", () => { selection: [table.availableEntityNames[0]], } const chart = new LineChart({ manager }) - expect(chart.series[0].seriesName).not.toContain(" - ") + expect(chart.series[0].seriesName).not.toContain(" – ") }) it("combines entity and column name if only one entity is selected and multi entity selection is enabled", () => { @@ -110,7 +110,7 @@ describe("series naming in multi-column mode", () => { selection: [table.availableEntityNames[0]], } const chart = new LineChart({ manager }) - expect(chart.series[0].seriesName).toContain(" - ") + expect(chart.series[0].seriesName).toContain(" – ") }) it("combines entity and column name if multiple entities are selected and multi entity selection is disabled", () => { @@ -120,7 +120,7 @@ describe("series naming in multi-column mode", () => { selection: table.availableEntityNames, } const chart = new LineChart({ manager }) - expect(chart.series[0].seriesName).toContain(" - ") + expect(chart.series[0].seriesName).toContain(" – ") }) }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 876a49b57bf..c1159cbc695 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -23,7 +23,6 @@ import { AxisAlign, Color, HorizontalAlign, - PrimitiveType, makeIdForHumanConsumption, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" @@ -102,6 +101,13 @@ import { HorizontalColorLegendManager, HorizontalNumericColorLegend, } from "../horizontalColorLegend/HorizontalColorLegends" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "./LineChartHelpers" const LINE_CHART_CLASS_NAME = "LineChart" @@ -402,13 +408,9 @@ export class LineChart // if entities with partial data are not plotted, // make sure they don't show up in the entity selector if (this.missingDataStrategy === MissingDataStrategy.hide) { - table = table.replaceNonNumericCellsWithErrorValues( - this.yColumnSlugs - ) - - table = table.dropEntitiesThatHaveNoDataInSomeColumn( - this.yColumnSlugs - ) + table = table + .replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) + .dropEntitiesThatHaveNoDataInSomeColumn(this.yColumnSlugs) } return table @@ -437,10 +439,10 @@ export class LineChart let table = this.transformedTableFromGrapher // The % growth transform cannot be applied in transformTable() because it will filter out // any rows before startHandleTimeBound and change the timeline bounds. - const { isRelativeMode, startHandleTimeBound } = this.manager - if (isRelativeMode && startHandleTimeBound !== undefined) { + const { isRelativeMode, startTime } = this.manager + if (isRelativeMode && startTime !== undefined) { table = table.toTotalGrowthForEachColumnComparedToStartTime( - startHandleTimeBound, + startTime, this.manager.yColumnSlugs ?? [] ) } @@ -707,7 +709,10 @@ export class LineChart rows={sortedData.map((series) => { const { seriesName: name, isProjection: striped } = series - const annotation = this.getAnnotationsForSeries(name) + const annotation = getAnnotationsForSeries( + this.annotationsMap, + name + ) const point = series.points.find( (point) => point.x === target.x @@ -1148,24 +1153,8 @@ export class LineChart // End of color legend props - // todo: for now just works with 1 y column - @computed private get annotationsMap(): Map< - PrimitiveType, - Set - > { - return this.inputTable - .getAnnotationColumnForColumn(this.yColumnSlugs[0]) - ?.getUniqueValuesGroupedBy(this.inputTable.entityNameSlug) - } - - getAnnotationsForSeries(seriesName: SeriesName): string | undefined { - const annotationsMap = this.annotationsMap - const annos = annotationsMap?.get(seriesName) - return annos - ? Array.from(annos.values()) - .filter((anno) => anno) - .join(" & ") - : undefined + @computed private get annotationsMap(): AnnotationsMap | undefined { + return getAnnotationsMap(this.inputTable, this.yColumnSlugs[0]) } @computed private get colorScheme(): ColorScheme { @@ -1196,39 +1185,6 @@ export class LineChart }) } - private getSeriesName( - entityName: EntityName, - columnName: string, - entityCount: number - ): SeriesName { - if (this.seriesStrategy === SeriesStrategy.entity) { - return entityName - } - if (entityCount > 1 || this.manager.canSelectMultipleEntities) { - return `${entityName} - ${columnName}` - } else { - return columnName - } - } - - private getColorKey( - entityName: EntityName, - columnName: string, - entityCount: number - ): SeriesName { - if (this.seriesStrategy === SeriesStrategy.entity) { - return entityName - } - // If only one entity is plotted, we want to use the column colors. - // Unlike in `getSeriesName`, we don't care whether the user can select - // multiple entities, only whether more than one is plotted. - if (entityCount > 1) { - return `${entityName} - ${columnName}` - } else { - return columnName - } - } - // cache value for performance @computed private get rowIndicesByEntityName(): Map { return this.transformedTable.rowIndex([ @@ -1237,14 +1193,20 @@ export class LineChart } private constructSingleSeries( - entityName: string, - col: CoreColumn + entityName: EntityName, + column: CoreColumn ): LineChartSeries { - const { hasColorScale, transformedTable, colorColumn } = this + const { + manager: { canSelectMultipleEntities = false }, + transformedTable: { availableEntityNames }, + seriesStrategy, + hasColorScale, + colorColumn, + } = this // Construct the points - const timeValues = col.originalTimeColumn.valuesIncludingErrorValues - const values = col.valuesIncludingErrorValues + const timeValues = column.originalTimeColumn.valuesIncludingErrorValues + const values = column.valuesIncludingErrorValues const colorValues = colorColumn.valuesIncludingErrorValues // If Y and Color are the same column, we need to get rid of any duplicate rows. // Duplicates occur because Y doesn't have tolerance applied, but Color does. @@ -1269,26 +1231,34 @@ export class LineChart }) // Construct series properties - const totalEntityCount = transformedTable.availableEntityNames.length - const seriesName = this.getSeriesName( + const columnName = column.nonEmptyDisplayName + const seriesName = getSeriesName({ entityName, - col.displayName, - totalEntityCount - ) + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, + }) + let seriesColor: Color if (hasColorScale) { const colorValue = last(points)?.colorValue seriesColor = this.getColorScaleColor(colorValue) } else { seriesColor = this.categoricalColorAssigner.assign( - this.getColorKey(entityName, col.displayName, totalEntityCount) + getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + }) ) } return { points, seriesName, - isProjection: col.isProjection, + isProjection: column.isProjection, color: seriesColor, } } @@ -1350,7 +1320,10 @@ export class LineChart seriesName, // E.g. https://ourworldindata.org/grapher/size-poverty-gap-world label: !this.manager.showLegend ? "" : `${seriesName}`, - annotation: this.getAnnotationsForSeries(seriesName), + annotation: getAnnotationsForSeries( + this.annotationsMap, + seriesName + ), yValue: lastValue, } }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts index e7e9a01cf7e..fe5d5a258bb 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts @@ -46,5 +46,5 @@ export interface LinesProps { export interface LineChartManager extends ChartManager { entityYearHighlight?: EntityYearHighlight lineStrokeWidth?: number - canSelectMultipleEntities?: boolean + canSelectMultipleEntities?: boolean // used to pick an appropriate series name } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts new file mode 100644 index 00000000000..49a1f81597a --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartHelpers.ts @@ -0,0 +1,75 @@ +import { OwidTable } from "@ourworldindata/core-table" +import { + ColumnSlug, + EntityName, + PrimitiveType, + SeriesName, + SeriesStrategy, +} from "@ourworldindata/types" + +export type AnnotationsMap = Map> + +export function getSeriesName({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, +}: { + entityName: EntityName + columnName: string + seriesStrategy: SeriesStrategy + availableEntityNames: EntityName[] + canSelectMultipleEntities: boolean +}): SeriesName { + // if entities are plotted, use the entity name + if (seriesStrategy === SeriesStrategy.entity) return entityName + + // if columns are plotted, use the column name + // and prepend the entity name if multiple entities can be selected + return availableEntityNames.length > 1 || canSelectMultipleEntities + ? `${entityName} – ${columnName}` + : columnName +} + +export function getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, +}: { + entityName: EntityName + columnName: string + seriesStrategy: SeriesStrategy + availableEntityNames: EntityName[] +}): SeriesName { + // if entities are plotted, use the entity name + if (seriesStrategy === SeriesStrategy.entity) return entityName + + // If only one entity is plotted, we want to use the column colors. + // Unlike in `getSeriesName`, we don't care whether the user can select + // multiple entities, only whether more than one is plotted. + return availableEntityNames.length > 1 + ? `${entityName} - ${columnName}` + : columnName +} + +export function getAnnotationsMap( + table: OwidTable, + slug: ColumnSlug +): AnnotationsMap | undefined { + return table + .getAnnotationColumnForColumn(slug) + ?.getUniqueValuesGroupedBy(table.entityNameSlug) +} + +export function getAnnotationsForSeries( + annotationsMap: AnnotationsMap | undefined, + seriesName: SeriesName +): string | undefined { + const annotations = annotationsMap?.get(seriesName) + if (!annotations) return undefined + return Array.from(annotations.values()) + .filter((anno) => anno) + .join(" & ") +} diff --git a/packages/@ourworldindata/grapher/src/noDataModal/NoDataModal.tsx b/packages/@ourworldindata/grapher/src/noDataModal/NoDataModal.tsx index 8502da6e0fe..a63ea9999b1 100644 --- a/packages/@ourworldindata/grapher/src/noDataModal/NoDataModal.tsx +++ b/packages/@ourworldindata/grapher/src/noDataModal/NoDataModal.tsx @@ -30,6 +30,7 @@ export interface NoDataModalManager { export class NoDataModal extends React.Component<{ bounds?: Bounds message?: string + helpText?: string manager: NoDataModalManager }> { @computed private get bounds(): Bounds { @@ -55,11 +56,12 @@ export class NoDataModal extends React.Component<{ isStatic, } = this.manager - const helpText = canAddEntities + const defaultHelpText = canAddEntities ? `Try adding ${entityTypePlural} to display data.` : canChangeEntity ? `Try choosing ${a(entityType)} to display data.` : undefined + const helpText = this.props.helpText ?? defaultHelpText const center = bounds.centerPos const padding = 0.75 * this.fontSize diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx index 6ccac9dd108..bf9dfb192f3 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx @@ -6,19 +6,19 @@ import { } from "../core/GrapherConstants" export function NoDataSection({ - entityNames, + seriesNames, bounds, baseFontSize = 16, }: { - entityNames: string[] + seriesNames: string[] bounds: Bounds baseFontSize?: number }): React.ReactElement { { - const displayedEntities = entityNames.slice(0, 5) - const numRemainingEntities = Math.max( + const displayedNames = seriesNames.slice(0, 5) + const remaining = Math.max( 0, - entityNames.length - displayedEntities.length + seriesNames.length - displayedNames.length ) return ( @@ -40,7 +40,7 @@ export function NoDataSection({ No data
    - {displayedEntities.map((entityName) => ( + {displayedNames.map((entityName) => (
  • ))}
- {numRemainingEntities > 0 && ( -
- &{" "} - {numRemainingEntities === 1 - ? "one" - : numRemainingEntities}{" "} - more -
+ {remaining > 0 && ( +
& {remaining === 1 ? "one" : remaining} more
)} ) diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index e4688cad8ee..5906adeb482 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -871,7 +871,7 @@ export class ScatterPlotChart {!this.manager.isStatic && separatorLine(noDataSectionBounds.top)} @@ -935,7 +935,9 @@ export class ScatterPlotChart timeLabel = timeRange + (isRelativeMode ? " (avg. annual change)" : "") - const columns = [xColumn, yColumn, sizeColumn] + const columns = [xColumn, yColumn, sizeColumn].filter( + (column) => !column.isMissing + ) const allRoundedToSigFigs = columns.every( (column) => column.roundsToSignificantFigures ) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts index 7d7e4ae8d80..bdfb7d1885c 100755 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts @@ -1,19 +1,29 @@ #! /usr/bin/env jest -import { SlopeChart } from "./SlopeChart" +import { SlopeChart, SlopeChartManager } from "./SlopeChart" import { + ErrorValueTypes, + OwidTable, SampleColumnSlugs, + SynthesizeFruitTableWithNonPositives, SynthesizeFruitTableWithStringValues, SynthesizeGDPTable, } from "@ourworldindata/core-table" import { ChartManager } from "../chart/ChartManager" -import { DEFAULT_SLOPE_CHART_COLOR } from "./SlopeChartConstants" -import { isNumber, OwidTableSlugs } from "@ourworldindata/utils" +import { + ColumnTypeNames, + FacetStrategy, + isNumber, + ScaleType, + SeriesStrategy, +} from "@ourworldindata/utils" +import { SelectionArray } from "../selection/SelectionArray" const table = SynthesizeGDPTable({ timeRange: [2000, 2010] }) const manager: ChartManager = { table, yColumnSlug: SampleColumnSlugs.Population, + selection: table.availableEntityNames, } it("can create a new slope chart", () => { @@ -21,18 +31,31 @@ it("can create a new slope chart", () => { expect(chart.series.length).toEqual(2) }) -it("slope charts can have different colors", () => { +it("filters non-numeric values", () => { + const table = SynthesizeFruitTableWithStringValues( + { + entityCount: 2, + timeRange: [2000, 2002], + }, + 1, + 1 + ) const manager: ChartManager = { table, - yColumnSlug: SampleColumnSlugs.Population, - colorColumnSlug: OwidTableSlugs.entityName, + yColumnSlugs: [SampleColumnSlugs.Fruit], + selection: table.availableEntityNames, } const chart = new SlopeChart({ manager }) - expect(chart.series[0].color).not.toEqual(DEFAULT_SLOPE_CHART_COLOR) + expect(chart.series.length).toEqual(1) + expect( + chart.series.every( + (series) => isNumber(series.startValue) && isNumber(series.endValue) + ) + ).toBeTruthy() }) -it("filters non-numeric values", () => { - const table = SynthesizeFruitTableWithStringValues( +it("can filter points with negative values when using a log scale", () => { + const table = SynthesizeFruitTableWithNonPositives( { entityCount: 2, timeRange: [2000, 2002], @@ -40,18 +63,200 @@ it("filters non-numeric values", () => { 1, 1 ) + const manager: ChartManager = { table, yColumnSlugs: [SampleColumnSlugs.Fruit], selection: table.availableEntityNames, } const chart = new SlopeChart({ manager }) - expect(chart.series.length).toEqual(1) - expect( - chart.series.every((series) => - series.values.every( - (value) => isNumber(value.x) && isNumber(value.y) - ) + // expect(chart.series.length).toEqual(2) + expect(chart.allYValues.length).toEqual(4) + + const logScaleManager = { + ...manager, + yAxisConfig: { + scaleType: ScaleType.log, + }, + } + const logChart = new SlopeChart({ manager: logScaleManager }) + expect(logChart.yAxis.domain[0]).toBeGreaterThan(0) + // expect(logChart.series.length).toEqual(2) + expect(logChart.allYValues.length).toEqual(2) +}) + +describe("series naming in multi-column mode", () => { + const table = SynthesizeGDPTable() + + it("only displays column name if only one entity is selected and multi entity selection is disabled", () => { + const manager = { + table, + canSelectMultipleEntities: false, + selection: [table.availableEntityNames[0]], + } + const chart = new SlopeChart({ manager }) + expect(chart.series[0].seriesName).not.toContain(" – ") + }) + + it("combines entity and column name if only one entity is selected and multi entity selection is enabled", () => { + const manager = { + table, + canSelectMultipleEntities: true, + selection: [table.availableEntityNames[0]], + } + const chart = new SlopeChart({ manager }) + expect(chart.series[0].seriesName).toContain(" – ") + }) + + it("combines entity and column name if multiple entities are selected and multi entity selection is disabled", () => { + const selection = new SelectionArray( + table.availableEntityNames, + table.availableEntities ) - ).toBeTruthy() + const manager = { + table, + canSelectMultipleEntities: false, + selection, + } + const chart = new SlopeChart({ manager }) + expect(chart.series[0].seriesName).toContain(" – ") + }) +}) + +describe("colors", () => { + const table = new OwidTable({ + entityName: ["usa", "canada", "usa", "canada"], + year: [2000, 2000, 2001, 2001], + gdp: [100, 200, 200, 300], + entityColor: ["blue", "red", "blue", "red"], + }) + const selection = ["usa", "canada"] + it("can add custom colors", () => { + const manager = { + yColumnSlugs: ["gdp"], + table, + selection, + } + const chart = new SlopeChart({ manager }) + expect(chart.series.map((series) => series.color)).toEqual([ + "blue", + "red", + ]) + }) + + it("uses column color selections when series strategy is column", () => { + const table = new OwidTable( + { + entityName: ["usa", "usa"], + year: [2000, 2001], + gdp: [100, 200], + entityColor: ["blue", "blue"], + }, + [{ slug: "gdp", color: "green", type: ColumnTypeNames.Numeric }] + ) + + const manager: ChartManager = { + yColumnSlugs: ["gdp"], + table: table, + selection, + seriesStrategy: SeriesStrategy.column, + } + const chart = new SlopeChart({ manager }) + const series = chart.series + + expect(series).toHaveLength(1) + expect(series[0].color).toEqual("green") + }) + + it("can assign colors to selected entities and preserve those colors when selection changes when using a color map", () => { + const selection = new SelectionArray(["usa", "canada"]) + const manager: ChartManager = { + yColumnSlugs: ["gdp"], + table: table.dropColumns(["entityColor"]), + selection, + seriesColorMap: new Map(), + } + const chart = new SlopeChart({ manager }) + const series = chart.series + expect(series).toHaveLength(2) + + selection.deselectEntity("usa") + + const newSeries = chart.series + expect(newSeries).toHaveLength(1) + expect(newSeries[0].color).toEqual(series[1].color) + }) + + it("uses variable colors when only one entity selected (even if multiple can be selected with controls)", () => { + const table = new OwidTable( + { + entityName: ["usa", "usa", "canada"], + year: [2000, 2001, 2000], + gdp: [100, 200, 100], + pop: [100, 200, 100], + }, + [ + { slug: "gdp", color: "green", type: ColumnTypeNames.Numeric }, + { slug: "pop", color: "orange", type: ColumnTypeNames.Numeric }, + ] + ) + + const manager: SlopeChartManager = { + yColumnSlugs: ["gdp", "pop"], + table: table, + selection: ["usa"], + seriesStrategy: SeriesStrategy.column, + facetStrategy: FacetStrategy.entity, + canSelectMultipleEntities: true, + } + const chart = new SlopeChart({ manager }) + const series = chart.series + + expect(series).toHaveLength(2) + expect(series[0].color).toEqual("green") + expect(series[1].color).toEqual("orange") + }) + + it("doesn't use variable colors if 2 variables have single entities which are different", () => { + const table = new OwidTable( + { + entityName: ["usa", "usa", "canada", "canada"], + year: [2000, 2001, 2000, 2001], + gdp: [ + 100, + 200, + ErrorValueTypes.MissingValuePlaceholder, + ErrorValueTypes.MissingValuePlaceholder, + ], + pop: [ + ErrorValueTypes.MissingValuePlaceholder, + ErrorValueTypes.MissingValuePlaceholder, + 100, + 200, + ], + }, + [ + { slug: "gdp", color: "green", type: ColumnTypeNames.Numeric }, + { slug: "pop", color: "orange", type: ColumnTypeNames.Numeric }, + ] + ) + + const selection = new SelectionArray( + ["usa", "canada"], + [{ entityName: "usa" }, { entityName: "canada" }] + ) + const manager: SlopeChartManager = { + yColumnSlugs: ["gdp", "pop"], + table: table, + selection, + seriesStrategy: SeriesStrategy.column, + canSelectMultipleEntities: true, + } + const chart = new SlopeChart({ manager }) + const series = chart.series + + expect(series).toHaveLength(2) + expect(series[0].color).not.toEqual("green") + expect(series[1].color).not.toEqual("orange") + }) }) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 8b865b803d6..8e928278f04 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -1,82 +1,91 @@ -import React from "react" +import React, { SVGProps } from "react" import { Bounds, DEFAULT_BOUNDS, - intersection, - without, - uniq, - isEmpty, - last, - sortBy, - max, - getRelativeMouse, domainExtent, - minBy, exposeInstanceOnWindow, PointVector, clamp, - HorizontalAlign, - difference, makeIdForHumanConsumption, + guid, + excludeUndefined, + partition, + max, + getRelativeMouse, + minBy, } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { NoDataModal } from "../noDataModal/NoDataModal" -import { - VerticalColorLegend, - VerticalColorLegendManager, -} from "../verticalColorLegend/VerticalColorLegend" -import { ColorScale, ColorScaleManager } from "../color/ColorScale" import { BASE_FONT_SIZE, + GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, - GRAPHER_FONT_SCALE_9_6, - GRAPHER_FONT_SCALE_10_5, + GRAPHER_FONT_SCALE_12, } from "../core/GrapherConstants" import { ScaleType, - EntitySelectionMode, - Color, SeriesName, ColorSchemeName, + ColumnSlug, + MissingDataStrategy, + Time, + SeriesStrategy, + EntityName, + RenderMode, } from "@ourworldindata/types" import { ChartInterface } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" import { scaleLinear, ScaleLinear } from "d3-scale" import { select } from "d3-selection" import { - DEFAULT_SLOPE_CHART_COLOR, - LabelledSlopesProps, + PlacedSlopeChartSeries, + RawSlopeChartSeries, SlopeChartSeries, - SlopeChartValue, - SlopeEntryProps, } from "./SlopeChartConstants" -import { OwidTable } from "@ourworldindata/core-table" +import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { + autoDetectSeriesStrategy, autoDetectYColumnSlugs, + getDefaultFailMessage, makeSelectionArray, - isElementInteractive, } from "../chart/ChartUtils" -import { AxisConfig, AxisManager } from "../axis/AxisConfig" +import { AxisConfig } from "../axis/AxisConfig" import { VerticalAxis } from "../axis/Axis" import { VerticalAxisComponent } from "../axis/AxisViews" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, -} from "../horizontalColorLegend/HorizontalColorLegends" -import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { NoDataSection } from "../scatterCharts/NoDataSection" +import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" +import { ColorScheme } from "../color/ColorScheme" +import { ColorSchemes } from "../color/ColorSchemes" +import { LineLabelSeries, LineLegend } from "../lineLegend/LineLegend" +import { + makeTooltipRoundingNotice, + Tooltip, + TooltipState, + TooltipValueRange, +} from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "../lineCharts/LineChartHelpers" + +type SVGMouseOrTouchEvent = + | React.MouseEvent + | React.TouchEvent export interface SlopeChartManager extends ChartManager { - isModalOpen?: boolean + canSelectMultipleEntities?: boolean // used to pick an appropriate series name + hasTimeline?: boolean // used to filter the table for the entity selector } -const LABEL_SLOPE_PADDING = 8 -const LABEL_LABEL_PADDING = 2 +const TOP_PADDING = 6 // leave room for overflowing dots +const BOTTOM_PADDING = 20 // leave room for the x-axis -const TOP_PADDING = 6 -const BOTTOM_PADDING = 20 +const LINE_LEGEND_PADDING = 4 @observer export class SlopeChart @@ -84,718 +93,310 @@ export class SlopeChart bounds?: Bounds manager: SlopeChartManager }> - implements - ChartInterface, - VerticalColorLegendManager, - HorizontalColorLegendManager, - ColorScaleManager + implements ChartInterface { - // currently hovered individual series key - @observable hoverKey?: string - // currently hovered legend color - @observable hoverColor?: string + slopeAreaRef: React.RefObject = React.createRef() + defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines - private hasInteractedWithChart = false + @observable hoveredSeriesName?: string + @observable tooltipState = new TooltipState<{ + series: SlopeChartSeries + }>({ fade: "immediate" }) transformTable(table: OwidTable) { - if (!table.has(this.yColumnSlug)) return table + table = table.filterByEntityNames( + this.selectionArray.selectedEntityNames + ) // TODO: remove this filter once we don't have mixed type columns in datasets - table = table.replaceNonNumericCellsWithErrorValues([this.yColumnSlug]) - - return table - .dropRowsWithErrorValuesForColumn(this.yColumnSlug) - .interpolateColumnWithTolerance(this.yColumnSlug) - } - - @computed get manager() { - return this.props.manager - } - - @computed.struct get bounds() { - return this.props.bounds ?? DEFAULT_BOUNDS - } - - @computed get isStatic(): boolean { - return this.manager.isStatic ?? false - } - - @computed get fontSize() { - return this.manager.fontSize ?? BASE_FONT_SIZE - } - - @computed private get isPortrait(): boolean { - return !!(this.manager.isNarrow || this.manager.isStaticAndSmall) - } - - @computed private get showHorizontalLegend(): boolean { - return !!(this.manager.isSemiNarrow || this.manager.isStaticAndSmall) - } - - // used by the component - @computed get legendItems() { - return this.colorScale.legendBins - .filter((bin) => this.colorsInUse.includes(bin.color)) - .map((bin) => { - return { - key: bin.label ?? "", - label: bin.label ?? "", - color: bin.color, - } - }) - } + table = table.replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) - // used by the component - @computed get categoricalLegendData(): CategoricalBin[] { - return this.legendItems.map( - (legendItem, index) => - new CategoricalBin({ - ...legendItem, - index, - value: legendItem.label, - }) - ) - } + if (this.isLogScale) + table = table.replaceNonPositiveCellsForLogScale(this.yColumnSlugs) - @action.bound onSlopeMouseOver(slopeProps: SlopeEntryProps) { - this.hoverKey = slopeProps.seriesName + return table } - @action.bound onSlopeMouseLeave() { - this.hoverKey = undefined - } + transformTableForSelection(table: OwidTable): OwidTable { + table = table.replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) - @action.bound onSlopeClick() { - const { hoverKey, isEntitySelectionEnabled } = this - if (!isEntitySelectionEnabled || hoverKey === undefined) { - return + // if time selection is disabled, then filter all entities that + // don't have data for the current time period + if (!this.manager.hasTimeline && this.startTime !== this.endTime) { + table = table + .filterByTargetTimes([this.startTime, this.endTime]) + .dropEntitiesThatHaveSomeMissingOrErrorValueInAllColumns( + this.yColumnSlugs + ) } - this.hasInteractedWithChart = true - this.selectionArray.toggleSelection(hoverKey) - } - - // Both legend managers accept a `onLegendMouseOver` property, but define different signatures. - // The component expects a string, - // the component expects a ColorScaleBin. - @action.bound onLegendMouseOver(binOrColor: string | ColorScaleBin) { - this.hoverColor = - typeof binOrColor === "string" ? binOrColor : binOrColor.color - } - @action.bound onLegendMouseLeave() { - this.hoverColor = undefined - } - - @computed private get selectionArray() { - return makeSelectionArray(this.manager.selection) - } + // if entities with partial data are not plotted, + // make sure they don't show up in the entity selector + if (this.missingDataStrategy === MissingDataStrategy.hide) { + table = table.dropEntitiesThatHaveNoDataInSomeColumn( + this.yColumnSlugs + ) + } - @computed private get selectedEntityNames() { - return this.selectionArray.selectedEntityNames + return table } - @computed get isEntitySelectionEnabled(): boolean { - const { manager } = this - return !!( - manager.addCountryMode !== EntitySelectionMode.Disabled && - manager.addCountryMode + @computed get transformedTableFromGrapher(): OwidTable { + return ( + this.manager.transformedTable ?? + this.transformTable(this.inputTable) ) } - // When the color legend is clicked, toggle selection fo all associated keys - @action.bound onLegendClick() { - const { hoverColor, isEntitySelectionEnabled } = this - if (!isEntitySelectionEnabled || hoverColor === undefined) return - - this.hasInteractedWithChart = true - - const seriesNamesToToggle = this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - const areAllSeriesActive = - intersection(seriesNamesToToggle, this.selectedEntityNames) - .length === seriesNamesToToggle.length - if (areAllSeriesActive) - this.selectionArray.setSelectedEntities( - without(this.selectedEntityNames, ...seriesNamesToToggle) - ) - else - this.selectionArray.setSelectedEntities( - this.selectedEntityNames.concat(seriesNamesToToggle) - ) - } - - // Colors on the legend for which every matching group is focused - @computed get focusColors() { - const { colorsInUse } = this - return colorsInUse.filter((color) => { - const matchingSeriesNames = this.series - .filter((g) => g.color === color) - .map((g) => g.seriesName) - return ( - intersection(matchingSeriesNames, this.selectedEntityNames) - .length === matchingSeriesNames.length + @computed get transformedTable(): OwidTable { + let table = this.transformedTableFromGrapher + // The % growth transform cannot be applied in transformTable() because it will filter out + // any rows before startTime and change the timeline bounds. + const { isRelativeMode, startTime } = this.manager + if (isRelativeMode && startTime !== undefined) { + table = table.toTotalGrowthForEachColumnComparedToStartTime( + startTime, + this.yColumnSlugs ?? [] ) - }) + } + return table } - @computed get focusKeys() { - return this.selectedEntityNames + @computed private get manager(): SlopeChartManager { + return this.props.manager } - // All currently hovered group keys, combining the legend and the main UI - @computed.struct get hoverKeys() { - const { hoverColor, hoverKey } = this - - const hoverKeys = - hoverColor === undefined - ? [] - : uniq( - this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - ) - - if (hoverKey !== undefined) hoverKeys.push(hoverKey) - - return hoverKeys + @computed get inputTable(): OwidTable { + return this.manager.table } - // Colors currently on the chart and not greyed out - @computed get activeColors() { - const { hoverKeys, focusKeys } = this - const activeKeys = hoverKeys.concat(focusKeys) - - if (activeKeys.length === 0) - // No hover or focus means they're all active by default - return uniq(this.series.map((g) => g.color)) - - return uniq( - this.series - .filter((g) => activeKeys.indexOf(g.seriesName) !== -1) - .map((g) => g.color) - ) + @computed private get bounds(): Bounds { + return this.props.bounds ?? DEFAULT_BOUNDS } - // Only show colors on legend that are actually in use - @computed private get colorsInUse() { - return uniq(this.series.map((series) => series.color)) + @computed get fontSize() { + return this.manager.fontSize ?? BASE_FONT_SIZE } - @computed get legendAlign(): HorizontalAlign { - return HorizontalAlign.left + @computed private get isLogScale(): boolean { + return this.yScaleType === ScaleType.log } - @computed get verticalColorLegend(): VerticalColorLegend { - return new VerticalColorLegend({ manager: this }) + @computed private get missingDataStrategy(): MissingDataStrategy { + return this.manager.missingDataStrategy || MissingDataStrategy.auto } - @computed get horizontalColorLegend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + @computed private get selectionArray() { + return makeSelectionArray(this.manager.selection) } - @computed get legendHeight(): number { - return this.showHorizontalLegend - ? this.horizontalColorLegend.height - : this.verticalColorLegend.height + @computed private get formatColumn() { + return this.yColumns[0] } - @computed get legendWidth(): number { - return this.showHorizontalLegend - ? this.bounds.width - : this.verticalColorLegend.width + @computed private get lineStrokeWidth(): number { + const factor = this.manager.isStaticAndSmall ? 2 : 1 + return factor * 2 } - @computed get maxLegendWidth(): number { - return this.showHorizontalLegend - ? this.bounds.width - : this.bounds.width * 0.5 + @computed private get backgroundColor(): string { + return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT } - @computed private get sidebarWidth(): number { - // the min width is set to prevent the "No data" title from line breaking - return clamp(this.legendWidth, 51, this.maxLegendWidth) - } - - // correction is to account for the space taken by the legend - @computed private get innerBounds() { - const { sidebarWidth, showLegend, legendHeight } = this - let bounds = this.bounds - if (showLegend) { - bounds = this.showHorizontalLegend - ? bounds.padTop(legendHeight + 8) - : bounds.padRight(sidebarWidth + 16) - } - return bounds + @computed private get isFocusModeActive(): boolean { + return this.hoveredSeriesName !== undefined } - // verify the validity of data used to show legend - // this is for backwards compatibility with charts that were added without legend - // eg: https://ourworldindata.org/grapher/mortality-rate-improvement-by-cohort - @computed private get showLegend() { - const { colorsInUse } = this - const { legendBins } = this.colorScale - return legendBins.some((bin) => colorsInUse.includes(bin.color)) + @computed private get yColumns(): CoreColumn[] { + return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) } - @computed - private get selectedEntitiesWithoutData(): string[] { - return difference( - this.selectedEntityNames, - this.series.map((s) => s.seriesName) - ) + @computed protected get yColumnSlugs(): ColumnSlug[] { + return autoDetectYColumnSlugs(this.manager) } - @computed private get noDataSection(): React.ReactElement { - const bounds = new Bounds( - this.legendX, - this.legendY + this.legendHeight + 12, - this.sidebarWidth, - this.bounds.height - this.legendHeight - 12 - ) + @computed private get colorScheme(): ColorScheme { return ( - + (this.manager.baseColorScheme + ? ColorSchemes.get(this.manager.baseColorScheme) + : null) ?? ColorSchemes.get(this.defaultBaseColorScheme) ) } - render() { - if (this.failMessage) - return ( - - ) - - const { manager } = this.props - const { - series, - focusKeys, - hoverKeys, - innerBounds, - showLegend, - showHorizontalLegend, - selectedEntitiesWithoutData, - } = this - - const legend = showHorizontalLegend ? ( - - ) : ( - - ) - - return ( - - - {showLegend && legend} - {/* only show the "No data" section if there is space */} - {showLegend && - !showHorizontalLegend && - selectedEntitiesWithoutData.length > 0 && - this.noDataSection} - - ) + @computed private get startTime(): Time { + return this.transformedTable.minTime ?? 0 } - @computed get categoryLegendY(): number { - return this.bounds.top + @computed private get endTime(): Time { + return this.transformedTable.maxTime ?? 0 } - @computed get legendY() { - return this.bounds.top + @computed private get startX(): number { + return this.xScale(this.startTime) } - @computed get legendX(): number { - return this.showHorizontalLegend - ? this.bounds.left - : this.bounds.right - this.sidebarWidth + @computed private get endX(): number { + return this.xScale(this.endTime) } - @computed get failMessage() { - if (this.yColumn.isMissing) return "Missing Y column" - else if (isEmpty(this.series)) return "No matching data" - return "" + @computed get seriesStrategy(): SeriesStrategy { + return autoDetectSeriesStrategy(this.manager, true) } - colorScale = this.props.manager.colorScaleOverride ?? new ColorScale(this) - - @computed get colorScaleConfig() { - return this.manager.colorScale + @computed private get categoricalColorAssigner(): CategoricalColorAssigner { + return new CategoricalColorAssigner({ + colorScheme: this.colorScheme, + invertColorScheme: this.manager.invertColorScheme, + colorMap: + this.seriesStrategy === SeriesStrategy.entity + ? this.inputTable.entityNameColorIndex + : this.inputTable.columnDisplayNameToColorMap, + autoColorMapCache: this.manager.seriesColorMap, + }) } - @computed get colorScaleColumn() { - return ( - // For faceted charts, we have to get the values of inputTable before it's filtered by - // the faceting logic. - this.manager.colorScaleColumnOverride ?? this.colorColumn - ) + @computed private get annotationsMap(): AnnotationsMap | undefined { + return getAnnotationsMap(this.inputTable, this.yColumnSlugs[0]) } - defaultBaseColorScheme = ColorSchemeName.continents + private constructSingleSeries( + entityName: EntityName, + column: CoreColumn + ): RawSlopeChartSeries { + const { startTime, endTime, seriesStrategy } = this + const { canSelectMultipleEntities = false } = this.manager - @computed private get yColumn() { - return this.transformedTable.get(this.yColumnSlug) - } + const { availableEntityNames } = this.transformedTable + const columnName = column.nonEmptyDisplayName + const seriesName = getSeriesName({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, + }) - @computed protected get yColumnSlug() { - return autoDetectYColumnSlugs(this.manager)[0] - } + const valueByTime = + column.valueByEntityNameAndOriginalTime.get(entityName) + const startValue = valueByTime?.get(startTime) + const endValue = valueByTime?.get(endTime) - @computed private get colorColumn() { - // NB: This is tricky. Often it seems we use the Continent variable (123) for colors, but we only have 1 year for that variable, which - // would likely get filtered by any time filtering. So we need to jump up to the root table to get the color values we want. - // We should probably refactor this as part of a bigger color refactoring. - return this.inputTable.get(this.manager.colorColumnSlug) - } + const colorKey = getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + }) + const color = this.categoricalColorAssigner.assign(colorKey) - @computed get transformedTable() { - return ( - this.manager.transformedTable ?? - this.transformTable(this.inputTable) + const annotation = getAnnotationsForSeries( + this.annotationsMap, + seriesName ) - } - @computed get inputTable() { - return this.manager.table + return { + column, + seriesName, + entityName, + color, + startValue, + endValue, + annotation, + } } - // helper method to directly get the associated color value given an Entity - // dimension data saves color a level deeper. eg: { Afghanistan => { 2015: Asia|Color }} - // this returns that data in the form { Afghanistan => Asia } - @computed private get colorBySeriesName(): Map< - SeriesName, - Color | undefined - > { - const { colorScale, colorColumn } = this - if (colorColumn.isMissing) return new Map() - - const colorByEntity = new Map() - - colorColumn.valueByEntityNameAndOriginalTime.forEach( - (timeToColorMap, seriesName) => { - const values = Array.from(timeToColorMap.values()) - const key = last(values) - colorByEntity.set(seriesName, colorScale.getColor(key)) - } - ) - - return colorByEntity + private isSeriesValid( + series: RawSlopeChartSeries + ): series is SlopeChartSeries { + return series.startValue !== undefined && series.endValue !== undefined } - // click anywhere inside the Grapher frame to dismiss the current selection - @action.bound onGrapherClick(e: Event): void { - const target = e.target as HTMLElement - const isTargetInteractive = isElementInteractive(target) + // Usually we drop rows with missing data in the transformTable function. + // But if we did that for slope charts, we wouldn't know whether a slope + // has been dropped because it actually had no data or a sibling slope had + // no data. But we need that information for the "No data" section. That's + // why the filtering happens here, so that the noDataSeries can be populated + // correctly. + private shouldSeriesBePlotted( + series: RawSlopeChartSeries + ): series is SlopeChartSeries { + if (!this.isSeriesValid(series)) return false + + // when the missing data strategy is set to "hide", we might + // choose not to plot a valid series if ( - this.isEntitySelectionEnabled && - this.hasInteractedWithChart && - !this.hoverKey && - !this.hoverColor && - !this.manager.isModalOpen && - !isTargetInteractive + this.seriesStrategy === SeriesStrategy.column && + this.missingDataStrategy === MissingDataStrategy.hide ) { - this.selectionArray.clearSelection() - } - } - - @computed private get grapherElement() { - return this.manager.base?.current - } - - componentDidMount() { - if (this.grapherElement) { - // listening to "mousedown" instead of "click" fixes a bug - // where the current selection was incorrectly dismissed - // when the user drags the slider but releases the drag outside of the timeline - this.grapherElement.addEventListener( - "mousedown", - this.onGrapherClick + const allSeriesForEntity = this.rawSeriesByEntityName.get( + series.entityName ) - } - exposeInstanceOnWindow(this) - } - - componentWillUnmount(): void { - if (this.grapherElement) { - this.grapherElement.removeEventListener( - "mousedown", - this.onGrapherClick + return !!allSeriesForEntity?.every((series) => + this.isSeriesValid(series) ) } - } - - @computed get series() { - const column = this.yColumn - if (!column) return [] - - const { colorBySeriesName } = this - const { minTime, maxTime } = column - - const table = this.inputTable - - return column.uniqEntityNames - .map((seriesName) => { - const values: SlopeChartValue[] = [] - - const yValues = - column.valueByEntityNameAndOriginalTime.get(seriesName)! || - [] - - yValues.forEach((value, time) => { - if (time !== minTime && time !== maxTime) return - - values.push({ - x: time, - y: value, - }) - }) - - // sort values by time - const sortedValues = sortBy(values, (v) => v.x) - - const color = - table.getColorForEntityName(seriesName) ?? - colorBySeriesName.get(seriesName) ?? - DEFAULT_SLOPE_CHART_COLOR - - return { - seriesName, - color, - values: sortedValues, - } as SlopeChartSeries - }) - .filter((series) => series.values.length >= 2) - } -} - -@observer -class SlopeEntry extends React.Component { - line: SVGElement | null = null - - @computed get isInBackground() { - const { isLayerMode, isHovered, isFocused } = this.props - - if (!isLayerMode) return false - return !(isHovered || isFocused) + return true } - render() { - const { - x1, - y1, - x2, - y2, - color, - hasLeftLabel, - hasRightLabel, - leftValueLabel, - leftEntityLabel, - rightValueLabel, - rightEntityLabel, - leftEntityLabelBounds, - rightEntityLabelBounds, - isFocused, - isHovered, - isMultiHoverMode, - seriesName, - } = this.props - const { isInBackground } = this - - const lineColor = isInBackground ? "#e2e2e2" : color - const labelColor = isInBackground ? "#ccc" : GRAPHER_DARK_TEXT - const opacity = isHovered ? 1 : isFocused ? 0.7 : 0.5 - const lineStrokeWidth = - isHovered && !isMultiHoverMode ? 4 : isFocused ? 3 : 2 - - const showDots = isFocused || isHovered - const showValueLabels = isFocused || isHovered - const showLeftEntityLabel = isFocused || (isHovered && isMultiHoverMode) - - const sharedLabelProps = { - fill: labelColor, - style: { cursor: "default" }, - } - - return ( - - (this.line = el)} - x1={x1} - y1={y1} - x2={x2} - y2={y2} - stroke={lineColor} - strokeWidth={lineStrokeWidth} - opacity={opacity} - /> - {showDots && ( - <> - - - - )} - {/* value label on the left */} - {hasLeftLabel && - showValueLabels && - leftValueLabel.render( - x1 - LABEL_SLOPE_PADDING, - leftEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - textAnchor: "end", - }, - } - )} - {/* entity label on the left */} - {hasLeftLabel && - showLeftEntityLabel && - leftEntityLabel.render( - // -2px is a minor visual correction - leftEntityLabelBounds.x - 2, - leftEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - textAnchor: "end", - }, - } - )} - {/* value label on the right */} - {hasRightLabel && - showValueLabels && - rightValueLabel.render( - rightEntityLabelBounds.x + - rightEntityLabel.width + - LABEL_LABEL_PADDING, - rightEntityLabelBounds.y, - { - textProps: sharedLabelProps, - } - )} - {/* entity label on the right */} - {hasRightLabel && - rightEntityLabel.render( - rightEntityLabelBounds.x, - rightEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - fontWeight: - isFocused || isHovered ? "bold" : undefined, - }, - } - )} - + @computed private get rawSeries(): RawSlopeChartSeries[] { + return this.yColumns.flatMap((column) => + this.selectionArray.selectedEntityNames.map((entityName) => + this.constructSingleSeries(entityName, column) + ) ) } -} - -@observer -class LabelledSlopes - extends React.Component - implements AxisManager -{ - base: React.RefObject = React.createRef() - @computed private get data() { - return this.props.seriesArr - } - - @computed private get yColumn() { - return this.props.yColumn - } - - @computed private get manager() { - return this.props.manager - } - - @computed private get bounds() { - return this.props.bounds - } - - @computed get fontSize() { - return this.manager.fontSize ?? BASE_FONT_SIZE + @computed private get rawSeriesByEntityName(): Map< + SeriesName, + RawSlopeChartSeries[] + > { + const map = new Map() + this.rawSeries.forEach((series) => { + const { entityName } = series + if (!map.has(entityName)) map.set(entityName, []) + map.get(entityName)!.push(series) + }) + return map } - @computed private get focusedSeriesNames() { - return intersection( - this.props.focusKeys || [], - this.data.map((g) => g.seriesName) + @computed get series(): SlopeChartSeries[] { + return this.rawSeries.filter((series) => + this.shouldSeriesBePlotted(series) ) } - @computed private get hoveredSeriesNames() { - return intersection( - this.props.hoverKeys || [], - this.data.map((g) => g.seriesName) - ) - } + @computed private get placedSeries(): PlacedSlopeChartSeries[] { + const { yAxis, startX, endX } = this - // Layered mode occurs when any entity on the chart is hovered or focused - // Then, a special "foreground" set of entities is rendered over the background - @computed private get isLayerMode() { - return ( - this.hoveredSeriesNames.length > 0 || - this.focusedSeriesNames.length > 0 || - // if the user has selected entities that are not in the chart, - // we want to move all entities into the background - (this.props.focusKeys?.length > 0 && - this.focusedSeriesNames.length === 0) - ) - } + return this.series.map((series) => { + const startY = yAxis.place(series.startValue) + const endY = yAxis.place(series.endValue) - @computed private get isMultiHoverMode() { - return this.hoveredSeriesNames.length > 1 - } + const startPoint = new PointVector(startX, startY) + const endPoint = new PointVector(endX, endY) - @computed get isPortrait(): boolean { - return this.props.isPortrait + return { ...series, startPoint, endPoint } + }) } - @computed private get allValues() { - return this.props.seriesArr.flatMap((g) => g.values) + @computed + private get noDataSeries(): RawSlopeChartSeries[] { + return this.rawSeries.filter((series) => !this.isSeriesValid(series)) } - @computed private get xDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.x), - ScaleType.linear + @computed private get showNoDataSection(): boolean { + // nothing to show if there are no series with missing data + if (this.noDataSeries.length === 0) return false + + // we usually don't show the no data section if columns are plotted + // (since columns don't appear in the entity selector there is no need + // to explain that a column is missing – it just adds noise). but if + // the missing data strategy is set to hide, then we do want to give + // feedback as to why a slope is currently not rendered + return ( + this.seriesStrategy === SeriesStrategy.entity || + this.missingDataStrategy === MissingDataStrategy.hide ) } @@ -803,28 +404,19 @@ class LabelledSlopes return new AxisConfig(this.manager.yAxisConfig, this) } - @computed get yAxis(): VerticalAxis { - const axis = this.yAxisConfig.toVerticalAxis() - axis.domain = this.yDomain - axis.range = this.yRange - axis.formatColumn = this.yColumn - axis.label = "" - return axis + @computed get allYValues(): number[] { + return this.series.flatMap((series) => [ + series.startValue, + series.endValue, + ]) } - @computed private get yScaleType() { - return this.yAxisConfig.scaleType || ScaleType.linear + @computed private get yScaleType(): ScaleType { + return this.yAxisConfig.scaleType ?? ScaleType.linear } @computed private get yDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.y), - this.yScaleType || ScaleType.linear - ) - } - - @computed private get xDomain(): [number, number] { - return this.xDomainDefault + return domainExtent(this.allYValues, this.yScaleType) } @computed private get yDomain(): [number, number] { @@ -836,37 +428,24 @@ class LabelledSlopes ] } - @computed get yRange(): [number, number] { + @computed private get yRange(): [number, number] { return this.bounds .padTop(TOP_PADDING) .padBottom(BOTTOM_PADDING) .yRange() } - @computed get yAxisWidth(): number { - return this.yAxis.width + 5 // 5px account for the tick marks + @computed get yAxis(): VerticalAxis { + const axis = this.yAxisConfig.toVerticalAxis() + axis.domain = this.yDomain + axis.range = this.yRange + axis.formatColumn = this.yColumns[0] + axis.label = "" + return axis } - @computed get xRange(): [number, number] { - // take into account the space taken by the yAxis and slope labels - const bounds = this.bounds - .padLeft(this.yAxisWidth + 4) - .padLeft(this.maxLabelWidth) - .padRight(this.maxLabelWidth) - - // pick a reasonable width based on an ideal aspect ratio - const idealAspectRatio = 0.9 - const availableWidth = bounds.width - const idealWidth = idealAspectRatio * bounds.height - const slopeWidth = this.isPortrait - ? availableWidth - : clamp(idealWidth, 220, availableWidth) - - const leftRightPadding = (availableWidth - slopeWidth) / 2 - return bounds - .padLeft(leftRightPadding) - .padRight(leftRightPadding) - .xRange() + @computed private get yAxisWidth(): number { + return this.yAxis.width + 5 // 5px account for the tick marks } @computed private get xScale(): ScaleLinear { @@ -874,446 +453,587 @@ class LabelledSlopes return scaleLinear().domain(xDomain).range(xRange) } - @computed get maxLabelWidth(): number { - const { slopeLabels } = this - const maxLabelWidths = slopeLabels.map((slope) => { - const entityLabelWidth = slope.leftEntityLabel.width - const maxValueLabelWidth = Math.max( - slope.leftValueLabel.width, - slope.rightValueLabel.width - ) - return ( - entityLabelWidth + - maxValueLabelWidth + - LABEL_SLOPE_PADDING + - LABEL_LABEL_PADDING - ) - }) - return max(maxLabelWidths) ?? 0 + @computed private get xDomain(): [number, number] { + return [this.startTime, this.endTime] } - @computed get allowedLabelWidth() { - return this.bounds.width * 0.2 + @computed private get sidebarWidth(): number { + return this.showNoDataSection + ? clamp(this.bounds.width * 0.125, 60, 140) + : 0 } - @computed private get slopeLabels() { - const { isPortrait, yColumn, allowedLabelWidth: maxWidth } = this + @computed private get maxLabelWidth(): number { + // TODO: copied from line legend + const fontSize = + GRAPHER_FONT_SCALE_12 * (this.manager.fontSize ?? BASE_FONT_SIZE) + return ( + max( + this.series.map( + (series) => + Bounds.forText(series.seriesName, { fontSize }).width + ) + ) ?? 0 + ) + } - return this.data.map((series) => { - const text = series.seriesName - const [v1, v2] = series.values - const fontSize = - (isPortrait - ? GRAPHER_FONT_SCALE_9_6 - : GRAPHER_FONT_SCALE_10_5) * this.fontSize - const leftValueStr = yColumn.formatValueShort(v1.y) - const rightValueStr = yColumn.formatValueShort(v2.y) + @computed get maxLineLegendWidth(): number { + // todo: copied from line legend (left padding, marker margin) + return Math.min(this.maxLabelWidth + 35 + 4, this.bounds.width / 3) + } - // value labels - const valueLabelProps = { - maxWidth: Infinity, // no line break - fontSize, - lineHeight: 1, - } - const leftValueLabel = new TextWrap({ - text: leftValueStr, - ...valueLabelProps, - }) - const rightValueLabel = new TextWrap({ - text: rightValueStr, - ...valueLabelProps, - }) - - // entity labels - const entityLabelProps = { - ...valueLabelProps, - maxWidth, - fontWeight: 700, - separators: [" ", "-"], - } - const leftEntityLabel = new TextWrap({ - text, - ...entityLabelProps, - }) - const rightEntityLabel = new TextWrap({ - text, - ...entityLabelProps, - }) + @computed get xRange(): [number, number] { + const lineLegendWidth = this.maxLineLegendWidth + LINE_LEGEND_PADDING + + // pick a reasonable max width based on an ideal aspect ratio + const idealAspectRatio = 0.6 + const chartAreaWidth = this.bounds.width - this.sidebarWidth + const availableWidth = + chartAreaWidth - this.yAxisWidth - lineLegendWidth + const idealWidth = idealAspectRatio * this.bounds.height + const maxSlopeWidth = Math.min(idealWidth, availableWidth) + + let startX = + this.bounds.x + Math.max(0.25 * chartAreaWidth, this.yAxisWidth + 4) + let endX = + this.bounds.x + + chartAreaWidth - + Math.max(0.25 * chartAreaWidth, lineLegendWidth) + + const currentSlopeWidth = endX - startX + if (currentSlopeWidth > maxSlopeWidth) { + const padding = currentSlopeWidth - maxSlopeWidth + startX += padding / 2 + endX -= padding / 2 + } - return { - seriesName: series.seriesName, - leftValueLabel, - leftEntityLabel, - rightValueLabel, - rightEntityLabel, - } - }) + return [startX, endX] } - @computed private get initialSlopeData() { - const { data, slopeLabels, xScale, yAxis, yDomain } = this + @computed get lineLegendX(): number { + return this.xRange[1] + LINE_LEGEND_PADDING + } - const slopeData: SlopeEntryProps[] = [] + // used by LineLegend + @computed get focusedSeriesNames(): SeriesName[] { + return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] + } - data.forEach((series, i) => { - // Ensure values fit inside the chart - if ( - !series.values.every( - (d) => d.y >= yDomain[0] && d.y <= yDomain[1] - ) - ) - return - - const labels = slopeLabels[i] - const [v1, v2] = series.values - const [x1, x2] = [xScale(v1.x), xScale(v2.x)] - const [y1, y2] = [yAxis.place(v1.y), yAxis.place(v2.y)] - - slopeData.push({ - ...labels, - x1, - y1, - x2, - y2, - color: series.color, - seriesName: series.seriesName, - isFocused: false, - isHovered: false, - hasLeftLabel: true, - hasRightLabel: true, - } as SlopeEntryProps) + // used in LineLegend + @computed get labelSeries(): LineLabelSeries[] { + return this.series.map((series) => { + const { seriesName, color, endValue, annotation } = series + return { + color, + seriesName, + label: seriesName, + annotation, + yValue: endValue, + } }) + } - return slopeData + private playIntroAnimation() { + // Nice little intro animation + select(this.slopeAreaRef.current) + .selectAll(".slope") + .attr("stroke-dasharray", "100%") + .attr("stroke-dashoffset", "100%") + .transition() + .attr("stroke-dashoffset", "0%") } - @computed get backgroundGroups() { - return this.slopeData.filter( - (group) => !(group.isHovered || group.isFocused) - ) + componentDidMount() { + exposeInstanceOnWindow(this) + + if (!this.manager.disableIntroAnimation) { + this.playIntroAnimation() + } } - @computed get foregroundGroups() { - return this.slopeData.filter( - (group) => !!(group.isHovered || group.isFocused) - ) + private updateTooltipPosition(event: SVGMouseOrTouchEvent) { + const ref = this.manager.base?.current + if (ref) this.tooltipState.position = getRelativeMouse(ref, event) } - // Get the final slope data with hover focusing and collision detection - @computed get slopeData(): SlopeEntryProps[] { - const { focusedSeriesNames, hoveredSeriesNames } = this + private detectHoveredSlope(event: SVGMouseOrTouchEvent) { + const ref = this.slopeAreaRef.current + if (!ref) return - let slopeData = this.initialSlopeData + const mouse = getRelativeMouse(ref, event) + this.mouseFrame = requestAnimationFrame(() => { + if (this.placedSeries.length === 0) return - slopeData = slopeData.map((slope) => { - // used for collision detection - const leftEntityLabelBounds = new Bounds( - // labels on the left are placed like this: | - slope.x1 - - LABEL_SLOPE_PADDING - - slope.leftValueLabel.width - - LABEL_LABEL_PADDING, - slope.y1 - slope.leftEntityLabel.lines[0].height / 2, - slope.leftEntityLabel.width, - slope.leftEntityLabel.height - ) - const rightEntityLabelBounds = new Bounds( - // labels on the left are placed like this: | - slope.x2 + LABEL_SLOPE_PADDING, - slope.y2 - slope.rightEntityLabel.height / 2, - slope.rightEntityLabel.width, - slope.rightEntityLabel.height - ) - - // used to determine priority for labelling conflicts - const isFocused = focusedSeriesNames.includes(slope.seriesName) - const isHovered = hoveredSeriesNames.includes(slope.seriesName) + const distanceMap = new Map() + for (const series of this.placedSeries) { + distanceMap.set( + series, + PointVector.distanceFromPointToLineSegmentSq( + mouse, + series.startPoint, + series.endPoint + ) + ) + } - return { - ...slope, - leftEntityLabelBounds, - rightEntityLabelBounds, - isFocused, - isHovered, + const closestSlope = minBy(this.placedSeries, (s) => + distanceMap.get(s) + )! + const distanceSq = distanceMap.get(closestSlope)! + const tolerance = 10 + const toleranceSq = tolerance * tolerance + + if (closestSlope && distanceSq < toleranceSq) { + this.onSlopeMouseOver(closestSlope) + } else { + this.onSlopeMouseLeave() } }) + } - // How to work out which of two slopes to prioritize for labelling conflicts - function chooseLabel(s1: SlopeEntryProps, s2: SlopeEntryProps) { - if (s1.isHovered && !s2.isHovered) - // Hovered slopes always have priority - return s1 - else if (!s1.isHovered && s2.isHovered) return s2 - else if (s1.isFocused && !s2.isFocused) - // Focused slopes are next in priority - return s1 - else if (!s1.isFocused && s2.isFocused) return s2 - else if (s1.hasRightLabel && !s2.hasRightLabel) - // Slopes which already have one label are prioritized for the other side - return s1 - else if (!s1.hasRightLabel && s2.hasRightLabel) return s2 - else return s1 // Equal priority, just do the first one - } - - // Eliminate overlapping labels, one pass for each side - slopeData.forEach((s1) => { - slopeData.forEach((s2) => { - if ( - s1 !== s2 && - s1.hasRightLabel && - s2.hasRightLabel && - // entity labels don't necessarily share the same x position. - // that's why we check for vertical intersection only - s1.rightEntityLabelBounds.hasVerticalOverlap( - s2.rightEntityLabelBounds - ) - ) { - if (chooseLabel(s1, s2) === s1) s2.hasRightLabel = false - else s1.hasRightLabel = false - } - }) - }) + private hoverTimer?: NodeJS.Timeout + @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { + clearTimeout(this.hoverTimer) + this.hoveredSeriesName = seriesName + } - slopeData.forEach((s1) => { - slopeData.forEach((s2) => { - if ( - s1 !== s2 && - s1.hasLeftLabel && - s2.hasLeftLabel && - // entity labels don't necessarily share the same x position. - // that's why we check for vertical intersection only - s1.leftEntityLabelBounds.hasVerticalOverlap( - s2.leftEntityLabelBounds - ) - ) { - if (chooseLabel(s1, s2) === s1) s2.hasLeftLabel = false - else s1.hasLeftLabel = false - } - }) - }) + @action.bound onLineLegendMouseLeave(): void { + clearTimeout(this.hoverTimer) + this.hoverTimer = setTimeout(() => { + // wait before clearing selection in case the mouse is moving quickly over neighboring labels + this.hoveredSeriesName = undefined + }, 200) + } - // Order by focus/hover for draw order - slopeData = sortBy(slopeData, (slope) => - slope.isFocused || slope.isHovered ? 1 : 0 - ) + @action.bound onSlopeMouseOver(series: SlopeChartSeries) { + this.hoveredSeriesName = series.seriesName + this.tooltipState.target = { series } + } - return slopeData + @action.bound onSlopeMouseLeave() { + this.hoveredSeriesName = undefined + this.tooltipState.target = null } mouseFrame?: number + @action.bound onMouseMove(event: SVGMouseOrTouchEvent) { + this.updateTooltipPosition(event) + this.detectHoveredSlope(event) + } + @action.bound onMouseLeave() { if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) - if (this.props.onMouseLeave) this.props.onMouseLeave() - } - - @action.bound onMouseMove( - ev: React.MouseEvent | React.TouchEvent - ) { - if (this.base.current) { - const mouse = getRelativeMouse(this.base.current, ev.nativeEvent) - - this.mouseFrame = requestAnimationFrame(() => { - if (this.props.bounds.contains(mouse)) { - if (this.slopeData.length === 0) return - - const { x1: startX, x2: endX } = this.slopeData[0] - - // whether the mouse is over the chart area, - // the left label area, or the right label area - const mousePosition = - mouse.x < startX - ? "left" - : mouse.x > endX - ? "right" - : "chart" - - // don't track mouse movements when hovering over labels on the left - if (mousePosition === "left") { - this.props.onMouseLeave() - return - } - - const distToSlopeOrLabel = new Map< - SlopeEntryProps, - number - >() - for (const s of this.slopeData) { - // start and end point of a line - let p1: PointVector - let p2: PointVector - - if (mousePosition === "chart") { - // points define the slope line - p1 = new PointVector(s.x1, s.y1) - p2 = new PointVector(s.x2, s.y2) - } else { - const labelBox = s.rightEntityLabelBounds.toProps() - // points define a "strike-through" line that stretches from - // the end point of the slopes to the right side of the right label - const y = labelBox.y + labelBox.height / 2 - p1 = new PointVector(endX, y) - p2 = new PointVector(labelBox.x + labelBox.width, y) - } - - // calculate the distance to the slope or label - const dist = - PointVector.distanceFromPointToLineSegmentSq( - mouse, - p1, - p2 - ) - distToSlopeOrLabel.set(s, dist) - } - - const closestSlope = minBy(this.slopeData, (s) => - distToSlopeOrLabel.get(s) - ) - const distanceSq = distToSlopeOrLabel.get(closestSlope!)! - const tolerance = mousePosition === "chart" ? 20 : 10 - const toleranceSq = tolerance * tolerance - - if ( - closestSlope && - distanceSq < toleranceSq && - this.props.onMouseOver - ) { - this.props.onMouseOver(closestSlope) - } else { - this.props.onMouseLeave() - } - } - }) - } + this.onSlopeMouseLeave() } - @action.bound onClick() { - if (this.props.onClick) this.props.onClick() + @computed get failMessage(): string { + const message = getDefaultFailMessage(this.manager) + if (message) return message + else if (this.startTime === this.endTime) + return "No data to display for the selected time period" + return "" } - componentDidMount() { - if (!this.manager.disableIntroAnimation) { - this.playIntroAnimation() - } + @computed get helpMessage(): string | undefined { + if ( + this.failMessage === + "No data to display for the selected time period" + ) + return "Try dragging the time slider to display data." + return undefined } - private playIntroAnimation() { - // Nice little intro animation - select(this.base.current) - .select(".slopes") - .attr("stroke-dasharray", "100%") - .attr("stroke-dashoffset", "100%") - .transition() - .attr("stroke-dashoffset", "0%") + @computed get renderUid(): number { + return guid() } - renderGroups(groups: SlopeEntryProps[]) { - const { isLayerMode, isMultiHoverMode } = this + @computed get tooltip(): React.ReactElement | undefined { + const { + manager: { isRelativeMode }, + tooltipState: { target, position, fading }, + formatColumn, + startTime, + endTime, + } = this + + const { series } = target || {} + if (!series) return + + const title = isRelativeMode + ? `${series.seriesName}, ${formatColumn.formatTime(endTime)}` + : series.seriesName + + const timeRange = [startTime, endTime] + .map((t) => formatColumn.formatTime(t)) + .join(" to ") + const timeLabel = isRelativeMode + ? `% change since ${formatColumn.formatTime(startTime)}` + : timeRange + + const roundingNotice = series.column.roundsToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + [series.column.numSignificantFigures], + { plural: !isRelativeMode } + ), + } + : undefined + const footer = excludeUndefined([roundingNotice]) + + const values = isRelativeMode + ? [series.endValue] + : [series.startValue, series.endValue] + + return ( + (this.tooltipState.target = null)} + > + + + ) + } + + private makeMissingDataLabel(series: RawSlopeChartSeries): string { + const { seriesName } = series + const startTime = this.formatColumn.formatTime(this.startTime) + const endTime = this.formatColumn.formatTime(this.endTime) + if (series.startValue === undefined && series.endValue === undefined) { + return `${seriesName} (${startTime} & ${endTime})` + } else if (series.startValue === undefined) { + return `${seriesName} (${startTime})` + } else if (series.endValue === undefined) { + return `${seriesName} (${endTime})` + } + return seriesName + } - return groups.map((slope) => ( - + this.makeMissingDataLabel(series) + ) + + return ( + - )) + ) } - render() { - const { bounds, slopeData, xDomain, yAxis, yRange, onMouseMove } = this + private renderSlope( + series: PlacedSlopeChartSeries, + mode?: RenderMode + ): React.ReactElement { + return ( + + ) + } - if (isEmpty(slopeData)) - return + private renderSlopes() { + if (!this.isFocusModeActive) { + return this.placedSeries.map((series) => this.renderSlope(series)) + } - const { x1, x2 } = slopeData[0] - const [y1, y2] = yRange + const [focusedSeries, backgroundSeries] = partition( + this.placedSeries, + (series) => series.seriesName === this.hoveredSeriesName + ) return ( - - + {backgroundSeries.map((series) => + this.renderSlope(series, RenderMode.mute) + )} + {focusedSeries.map((series) => + this.renderSlope(series, RenderMode.focus) + )} + + ) + } + + private renderChartArea() { + const { bounds, xDomain, yRange, startX, endX } = this + + const [bottom, top] = yRange + + return ( + + - - {this.yAxis.tickLabels.map((tick) => { - const y = yAxis.place(tick.value) - return ( - - {/* grid lines connecting the chart area to the axis */} - - {/* grid lines within the chart area */} - - - ) - })} - - - - - {this.yColumn.formatTime(xDomain[0])} - - + + - {this.yColumn.formatTime(xDomain[1])} - - - {this.renderGroups(this.backgroundGroups)} - {this.renderGroups(this.foregroundGroups)} + + {this.renderSlopes()} ) } + + render() { + if (this.failMessage) + return ( + + ) + + return ( + + {this.renderChartArea()} + {this.manager.showLegend && } + {this.showNoDataSection && this.renderNoDataSection()} + {this.tooltip} + + ) + } +} + +interface SlopeProps { + series: PlacedSlopeChartSeries + color: string + mode?: RenderMode + dotRadius?: number + strokeWidth?: number + outlineWidth?: number + outlineStroke?: string + onMouseOver?: (series: SlopeChartSeries) => void + onMouseLeave?: () => void +} + +function Slope({ + series, + color, + mode = RenderMode.default, + dotRadius = 3.5, + strokeWidth = 2, + outlineWidth = 0.5, + outlineStroke = "#fff", + onMouseOver, + onMouseLeave, +}: SlopeProps) { + const { seriesName, startPoint, endPoint } = series + + const opacity = { + [RenderMode.default]: 1, + [RenderMode.focus]: 1, + [RenderMode.mute]: 0.3, + [RenderMode.background]: 0.3, + }[mode] + + return ( + onMouseOver?.(series)} + onMouseLeave={() => onMouseLeave?.()} + > + + + + + ) +} + +interface HaloLineProps extends SVGProps { + startPoint: PointVector + endPoint: PointVector + strokeWidth?: number + outlineWidth?: number + outlineStroke?: string +} + +function HaloLine(props: HaloLineProps): React.ReactElement { + const { + startPoint, + endPoint, + outlineWidth = 0.5, + outlineStroke = "#fff", + ...styleProps + } = props + return ( + <> + + + + ) +} + +interface GridLinesProps { + bounds: Bounds + yAxis: VerticalAxis + startX: number + endX: number +} + +function GridLines({ bounds, yAxis, startX, endX }: GridLinesProps) { + return ( + + {yAxis.tickLabels.map((tick) => { + const y = yAxis.place(tick.value) + return ( + + {/* grid lines connecting the chart area to the axis */} + + {/* grid lines within the chart area */} + + + ) + })} + + ) +} + +function MarkX({ + label, + x, + top, + bottom, + fontSize, +}: { + label: string + x: number + top: number + bottom: number + fontSize: number +}) { + return ( + <> + + + {label} + + + ) } diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts index bb52f727212..85cd862866e 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts @@ -1,62 +1,23 @@ -import { CoreColumn } from "@ourworldindata/core-table" +import { EntityName, PartialBy, PointVector } from "@ourworldindata/utils" import { ChartSeries } from "../chart/ChartInterface" -import { ChartManager } from "../chart/ChartManager" -import { ScaleType } from "@ourworldindata/types" -import { Bounds } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" - -export interface SlopeChartValue { - x: number - y: number -} +import { CoreColumn } from "@ourworldindata/core-table" export interface SlopeChartSeries extends ChartSeries { - size: number - values: SlopeChartValue[] + column: CoreColumn + entityName: EntityName + startValue: number + endValue: number + annotation?: string } -export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" - -export interface SlopeEntryProps extends ChartSeries { - isLayerMode: boolean - isMultiHoverMode: boolean - x1: number - y1: number - x2: number - y2: number +export type RawSlopeChartSeries = PartialBy< + SlopeChartSeries, + "startValue" | "endValue" +> - hasLeftLabel: boolean - leftEntityLabel: TextWrap - leftValueLabel: TextWrap - leftEntityLabelBounds: Bounds - - hasRightLabel: boolean - rightEntityLabel: TextWrap - rightEntityLabelBounds: Bounds - rightValueLabel: TextWrap - - isFocused: boolean - isHovered: boolean -} - -export interface LabelledSlopesProps { - manager: ChartManager - yColumn: CoreColumn - bounds: Bounds - seriesArr: SlopeChartSeries[] - focusKeys: string[] - hoverKeys: string[] - onMouseOver: (slopeProps: SlopeEntryProps) => void - onMouseLeave: () => void - onClick: () => void - isPortrait: boolean +export interface PlacedSlopeChartSeries extends SlopeChartSeries { + startPoint: PointVector + endPoint: PointVector } -export interface SlopeAxisProps { - bounds: Bounds - orient: "left" | "right" - column: CoreColumn - scale: any - scaleType: ScaleType - fontSize: number -} +export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index e218c1950c9..58eb7020266 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -18,7 +18,7 @@ import { max, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" -import { SeriesName } from "@ourworldindata/types" +import { RenderMode, SeriesName } from "@ourworldindata/types" import { GRAPHER_AREA_OPACITY_DEFAULT, GRAPHER_AREA_OPACITY_MUTE, @@ -68,21 +68,21 @@ interface AreasProps extends React.SVGAttributes { const STACKED_AREA_CHART_CLASS_NAME = "StackedArea" -const AREA_OPACITY = { - DEFAULT: GRAPHER_AREA_OPACITY_DEFAULT, - FOCUS: GRAPHER_AREA_OPACITY_FOCUS, - MUTE: GRAPHER_AREA_OPACITY_MUTE, +const AREA_OPACITY: Partial> = { + default: GRAPHER_AREA_OPACITY_DEFAULT, + focus: GRAPHER_AREA_OPACITY_FOCUS, + mute: GRAPHER_AREA_OPACITY_MUTE, } -const BORDER_OPACITY = { - DEFAULT: 0.7, - HOVER: 1, - MUTE: 0.3, +const BORDER_OPACITY: Partial> = { + default: 0.7, + focus: 1, + mute: 0.3, } -const BORDER_WIDTH = { - DEFAULT: 0.5, - HOVER: 1.5, +const BORDER_WIDTH: Partial> = { + default: 0.5, + mute: 1.5, } @observer @@ -183,10 +183,10 @@ class Areas extends React.Component { } const points = [...placedPoints, ...reverse(clone(prevPoints))] const opacity = !this.isFocusModeActive - ? AREA_OPACITY.DEFAULT // normal opacity + ? AREA_OPACITY.default // normal opacity : focusedSeriesName === series.seriesName - ? AREA_OPACITY.FOCUS // hovered - : AREA_OPACITY.MUTE // non-hovered + ? AREA_OPACITY.focus // hovered + : AREA_OPACITY.mute // non-hovered return ( { return placedSeriesArr.map((placedSeries) => { const opacity = !this.isFocusModeActive - ? BORDER_OPACITY.DEFAULT // normal opacity + ? BORDER_OPACITY.default // normal opacity : focusedSeriesName === placedSeries.seriesName - ? BORDER_OPACITY.HOVER // hovered - : BORDER_OPACITY.MUTE // non-hovered + ? BORDER_OPACITY.focus // hovered + : BORDER_OPACITY.mute // non-hovered const strokeWidth = focusedSeriesName === placedSeries.seriesName - ? BORDER_WIDTH.HOVER - : BORDER_WIDTH.DEFAULT + ? BORDER_WIDTH.focus + : BORDER_WIDTH.default return (