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 cf7dc20a1aa..34dc42551cb 100644 --- a/packages/@ourworldindata/core-table/src/OwidTable.ts +++ b/packages/@ourworldindata/core-table/src/OwidTable.ts @@ -124,9 +124,8 @@ export class OwidTable extends CoreTable { return min(this.allTimes) as Time } - // TODO: remove undefined? - @imemo get maxTime(): Time | undefined { - return max(this.allTimes) + @imemo get maxTime(): Time { + return max(this.allTimes) as Time } @imemo private get allTimes(): Time[] { diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 876a49b57bf..1fd9cf7ee11 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -102,6 +102,13 @@ import { HorizontalColorLegendManager, HorizontalNumericColorLegend, } from "../horizontalColorLegend/HorizontalColorLegends" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "./lineChartHelpers" const LINE_CHART_CLASS_NAME = "LineChart" @@ -1148,24 +1155,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 +1187,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 +1195,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 +1233,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 +1322,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/lineChartHelpers.ts b/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts index 0281613d6e6..5296a3a3eca 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts @@ -1,4 +1,13 @@ -import { EntityName, SeriesName, SeriesStrategy } from "@ourworldindata/types" +import { OwidTable } from "@ourworldindata/core-table" +import { + ColumnSlug, + EntityName, + PrimitiveType, + SeriesName, + SeriesStrategy, +} from "@ourworldindata/types" + +export type AnnotationsMap = Map> export function getSeriesName({ entityName, @@ -44,3 +53,23 @@ export function getColorKey({ ? `${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/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 12ad62c83c6..9055e520a16 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)} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts index 7d7e4ae8d80..9a22d9c08a6 100755 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts @@ -48,10 +48,8 @@ it("filters non-numeric values", () => { 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) - ) + chart.series.every( + (series) => isNumber(series.startValue) && isNumber(series.endValue) ) ).toBeTruthy() }) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 563cfbaa568..4e8ae8717c6 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -3,23 +3,27 @@ import { Bounds, DEFAULT_BOUNDS, isEmpty, - sortBy, - getRelativeMouse, domainExtent, - minBy, exposeInstanceOnWindow, PointVector, clamp, - difference, makeIdForHumanConsumption, guid, excludeUndefined, + partition, + max, + getRelativeMouse, + minBy, } from "@ourworldindata/utils" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { NoDataModal } from "../noDataModal/NoDataModal" import { ColorScaleManager } from "../color/ColorScale" -import { BASE_FONT_SIZE, GRAPHER_DARK_TEXT } from "../core/GrapherConstants" +import { + BASE_FONT_SIZE, + GRAPHER_DARK_TEXT, + GRAPHER_FONT_SCALE_12, +} from "../core/GrapherConstants" import { ScaleType, SeriesName, @@ -30,15 +34,16 @@ import { SeriesStrategy, EntityName, PrimitiveType, + 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 { + PlacedSlopeChartSeries, + RawSlopeChartSeries, SlopeChartSeries, - SlopeChartValue, - SlopeEntryProps, } from "./SlopeChartConstants" import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { @@ -62,15 +67,25 @@ import { TooltipValueRange, } from "../tooltip/Tooltip" import { TooltipFooterIcon } from "../tooltip/TooltipProps" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "../lineCharts/lineChartHelpers" export interface SlopeChartManager extends ChartManager { isModalOpen?: boolean + canChangeEntity?: boolean canSelectMultipleEntities?: boolean } const TOP_PADDING = 6 const BOTTOM_PADDING = 20 +const LINE_LEGEND_PADDING = 4 + @observer export class SlopeChart extends React.Component<{ @@ -80,13 +95,12 @@ export class SlopeChart implements ChartInterface, ColorScaleManager { base: React.RefObject = React.createRef() + defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines - // currently hovered individual series key - @observable hoverKey?: string - + @observable hoveredSeriesName?: string @observable tooltipState = new TooltipState<{ series: SlopeChartSeries - }>() + }>({ fade: "immediate" }) transformTable(table: OwidTable) { table = table.filterByEntityNames( @@ -109,50 +123,58 @@ export class SlopeChart } return table - - // TODO: re-enable? - // return table - // .dropRowsWithErrorValuesForColumn(this.yColumnSlug) - // .interpolateColumnWithTolerance(this.yColumnSlug) } transformTableForSelection(table: OwidTable): OwidTable { // 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 + .replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) + .dropEntitiesThatHaveNoDataInSomeColumn(this.yColumnSlugs) + } + + return table + } + + @computed get transformedTableFromGrapher(): OwidTable { + return ( + this.manager.transformedTable ?? + this.transformTable(this.inputTable) + ) + } - table = table.dropEntitiesThatHaveNoDataInSomeColumn( - this.yColumnSlugs + @computed get transformedTable(): OwidTable { + 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) { + table = table.toTotalGrowthForEachColumnComparedToStartTime( + startHandleTimeBound, + this.yColumnSlugs ?? [] ) } - return table } - @computed get manager() { + @computed private get manager(): SlopeChartManager { return this.props.manager } - @computed.struct get bounds() { - return this.props.bounds ?? DEFAULT_BOUNDS + @computed get inputTable(): OwidTable { + return this.manager.table } - @computed get isStatic(): boolean { - return this.manager.isStatic ?? false + @computed private get bounds(): Bounds { + return this.props.bounds ?? DEFAULT_BOUNDS } @computed get fontSize() { return this.manager.fontSize ?? BASE_FONT_SIZE } - @computed private get isPortrait(): boolean { - return !!(this.manager.isNarrow || this.manager.isStaticAndSmall) - } - - @computed get isLogScale(): boolean { + @computed private get isLogScale(): boolean { return this.props.manager.yAxisConfig?.scaleType === ScaleType.log } @@ -160,350 +182,338 @@ export class SlopeChart return this.manager.missingDataStrategy || MissingDataStrategy.auto } - @action.bound onSlopeMouseOver(slopeProps: SlopeEntryProps) { - this.hoverKey = slopeProps.seriesName - this.tooltipState.target = { - series: slopeProps.series, - } + @computed private get selectionArray() { + return makeSelectionArray(this.manager.selection) } - @action.bound onSlopeMouseLeave() { - this.hoverKey = undefined - this.tooltipState.target = null + @computed private get formatColumn() { + return this.yColumns[0] } - @computed private get selectionArray() { - return makeSelectionArray(this.manager.selection) + @computed private get sidebarWidth(): number { + return this.showNoDataSection + ? clamp(this.bounds.width * 0.125, 60, 140) + : 0 } - @computed private get selectedEntityNames() { - return this.selectionArray.selectedEntityNames + // used by LineLegend + @computed get focusedSeriesNames(): SeriesName[] { + return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] } - @computed private get sidebarWidth(): number { - return Math.min(120, 0.15 * this.bounds.width) + @computed private get isFocusModeActive(): boolean { + return this.hoveredSeriesName !== undefined } - @computed private get innerBounds() { - const { sidebarWidth } = this - let bounds = this.bounds - if (this.showNoDataSection) { - bounds = bounds.padRight(sidebarWidth + 16) - } - return bounds + @computed private get startX(): number { + return this.xScale(this.startTime) } - @computed - private get selectedEntitiesWithoutData(): string[] { - return difference( - this.selectedEntityNames, - this.series.map((s) => s.seriesName) - ) + @computed private get endX(): number { + return this.xScale(this.endTime) } - @computed private get showNoDataSection(): boolean { - // TODO: for now, only show missing data section for entities - return ( - this.seriesStrategy === SeriesStrategy.entity && - this.selectedEntitiesWithoutData.length > 0 - ) + private updateTooltipPosition( + event: React.MouseEvent | React.TouchEvent + ) { + const ref = this.manager.base?.current + if (ref) this.tooltipState.position = getRelativeMouse(ref, event) } - @computed private get noDataSection(): React.ReactElement { - const bounds = new Bounds( - this.bounds.right - this.sidebarWidth, - this.bounds.top, - this.sidebarWidth, - this.bounds.height - ) + private detectHoveredSlope( + event: React.MouseEvent | React.TouchEvent + ) { + const ref = this.base.current + if (!ref) return + + const mouse = getRelativeMouse(ref, event) + this.mouseFrame = requestAnimationFrame(() => { + if (this.placedSeries.length === 0) return + + const distToSlope = new Map() + for (const series of this.placedSeries) { + distToSlope.set( + series, + PointVector.distanceFromPointToLineSegmentSq( + mouse, + series.startPoint, + series.endPoint + ) + ) + } + + const closestSlope = minBy(this.placedSeries, (s) => + distToSlope.get(s) + ) + const distanceSq = distToSlope.get(closestSlope!)! + const tolerance = 10 + const toleranceSq = tolerance * tolerance + + if (closestSlope && distanceSq < toleranceSq) { + this.onSlopeMouseOver(closestSlope) + } else { + this.onSlopeMouseLeave() + } + }) + } + + @computed get failMessage() { + const message = getDefaultFailMessage(this.manager) + if (message) return message + else if (isEmpty(this.series)) return "No matching data" + return "" + } + + @computed private get yColumns(): CoreColumn[] { + return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) + } + + @computed protected get yColumnSlugs(): ColumnSlug[] { + return autoDetectYColumnSlugs(this.manager) + } + + @computed private get colorScheme(): ColorScheme { return ( - + (this.manager.baseColorScheme + ? ColorSchemes.get(this.manager.baseColorScheme) + : null) ?? ColorSchemes.get(this.defaultBaseColorScheme) ) } - // used by LineLegend - @computed get focusedSeriesNames(): string[] { - return this.hoverKey ? [this.hoverKey] : [] + @computed private get startTime(): Time { + return this.transformedTable.minTime } - // 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.hoverKey !== undefined + @computed private get endTime(): Time { + return this.transformedTable.maxTime } - @computed private get formatColumn() { - return this.yColumns[0] + @computed get seriesStrategy(): SeriesStrategy { + return autoDetectSeriesStrategy(this.manager, true) } - @computed get allowedLabelWidth() { - return this.bounds.width * 0.2 + @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 maxLabelWidth(): number { - // const maxLabelWidths = this.series.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 - return 100 // TODO: remove? + @computed private get annotationsMap(): AnnotationsMap | undefined { + return getAnnotationsMap(this.inputTable, this.yColumnSlugs[0]) } - @computed private get initialSlopeData() { - const { series, xScale, yAxis, yDomain } = this + private constructSingleSeries( + entityName: EntityName, + column: CoreColumn + ): RawSlopeChartSeries | undefined { + const { startTime, endTime, seriesStrategy } = this + const { canSelectMultipleEntities = false } = this.manager + const { availableEntityNames } = this.transformedTable + + const columnName = column.nonEmptyDisplayName + const seriesName = getSeriesName({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, + }) + + const valueByTime = + column.valueByEntityNameAndOriginalTime.get(entityName) + const startValue = valueByTime?.get(startTime) + const endValue = valueByTime?.get(endTime) + + const colorKey = getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + }) + const color = this.categoricalColorAssigner.assign(colorKey) + + const annotation = getAnnotationsForSeries( + this.annotationsMap, + seriesName + ) + + return { + seriesName, + color, + startValue, + endValue, + annotation, + } + } - const slopeData: SlopeEntryProps[] = [] + private isSeriesValid( + series: RawSlopeChartSeries + ): series is SlopeChartSeries { + return series.startValue !== undefined && series.endValue !== undefined + } - series.forEach((series) => { - // Ensure values fit inside the chart - if ( - !series.values.every( - (d) => d.y >= yDomain[0] && d.y <= yDomain[1] + @computed get rawSeries(): RawSlopeChartSeries[] { + return excludeUndefined( + this.yColumns.flatMap((column) => + column.uniqEntityNames.map((entityName) => + this.constructSingleSeries(entityName, column) ) ) - return - - 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({ - x1, - y1, - x2, - y2, - color: series.color, - series: series, - seriesName: series.seriesName, - isHovered: false, - } as SlopeEntryProps) + ) + } + + @computed get series(): SlopeChartSeries[] { + return this.rawSeries.filter(this.isSeriesValid) + } + + @computed private get placedSeries(): PlacedSlopeChartSeries[] { + const { yAxis, startX, endX } = this + + return this.series.map((series) => { + const startPoint = new PointVector( + startX, + yAxis.place(series.startValue) + ) + const endPoint = new PointVector(endX, yAxis.place(series.endValue)) + return { ...series, startPoint, endPoint } }) + } - return slopeData + @computed + private get noDataSeries(): RawSlopeChartSeries[] { + return this.rawSeries.filter((series) => !this.isSeriesValid(series)) } - mouseFrame?: number - @action.bound onMouseLeave() { - if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) + @computed private get showNoDataSection(): boolean { + return this.noDataSeries.length > 0 + } - this.onSlopeMouseLeave() + @computed private get yAxisConfig(): AxisConfig { + return new AxisConfig(this.manager.yAxisConfig, this) } - @action.bound onMouseMove( - ev: React.MouseEvent | React.TouchEvent - ) { - const ref = this.manager.base?.current - if (ref) { - this.tooltipState.position = getRelativeMouse(ref, ev) - } + @computed private get allValues(): number[] { + return this.series.flatMap((series) => [ + series.startValue, + series.endValue, + ]) + } - if (this.base.current) { - const mouse = getRelativeMouse(this.base.current, ev.nativeEvent) - - this.mouseFrame = requestAnimationFrame(() => { - if (this.innerBounds.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 or right - if (mousePosition === "left" || mousePosition === "right") { - this.onSlopeMouseLeave() - return - } - - const distToSlopeOrLabel = new Map< - SlopeEntryProps, - number - >() - for (const s of this.slopeData) { - // start and end point of a line - const p1 = new PointVector(s.x1, s.y1) - const p2 = new PointVector(s.x2, s.y2) - - // 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.onSlopeMouseOver(closestSlope) - } else { - this.onSlopeMouseLeave() - } - } - }) - } + @computed private get yScaleType(): ScaleType { + return this.yAxisConfig.scaleType ?? ScaleType.linear } - // Get the final slope data with hover focusing and collision detection - @computed get slopeData(): SlopeEntryProps[] { - let slopeData = this.initialSlopeData + @computed private get yDomainDefault(): [number, number] { + return domainExtent(this.allValues, this.yScaleType) + } - slopeData = slopeData.map((slope) => { - // used to determine priority for labelling conflicts - const isHovered = this.hoverKey === slope.seriesName + @computed private get yDomain(): [number, number] { + const domain = this.yAxisConfig.domain || [Infinity, -Infinity] + const domainDefault = this.yDomainDefault + return [ + Math.min(domain[0], domainDefault[0]), + Math.max(domain[1], domainDefault[1]), + ] + } - return { - ...slope, - isHovered, - } - }) + @computed get yRange(): [number, number] { + return this.bounds + .padTop(TOP_PADDING) + .padBottom(BOTTOM_PADDING) + .yRange() + } - // Order by focus/hover for draw order - slopeData = sortBy(slopeData, (slope) => (slope.isHovered ? 1 : 0)) + @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 + } - return slopeData + @computed get yAxisWidth(): number { + return this.yAxis.width + 5 // 5px account for the tick marks } - private renderGroups(groups: SlopeEntryProps[]) { - const { isLayerMode } = this + @computed private get xScale(): ScaleLinear { + const { xDomain, xRange } = this + return scaleLinear().domain(xDomain).range(xRange) + } - return groups.map((slope) => ( - - )) + @computed private get xDomain(): [number, number] { + return [this.startTime, this.endTime] } - private renderLabelledSlopes() { - const { bounds, slopeData, xDomain, yAxis, yRange, onMouseMove } = 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 + ) + )! + } - if (isEmpty(slopeData)) - return + @computed get maxLineLegendWidth(): number { + // todo: copied from line legend (left padding, marker margin) + return Math.min(this.maxLabelWidth + 35 + 4, this.bounds.width / 3) + } - const { x1, x2 } = slopeData[0] - const [y1, y2] = yRange + @computed get xRange(): [number, number] { + const lineLegendWidth = this.maxLineLegendWidth + LINE_LEGEND_PADDING - 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.formatColumn.formatTime(xDomain[0])} - - - {this.formatColumn.formatTime(xDomain[1])} - - - {this.renderGroups(this.backgroundGroups)} - {this.renderGroups(this.foregroundGroups)} - - - ) + // pick a reasonable 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 + + Math.min( + chartAreaWidth - 0.25 * chartAreaWidth, + chartAreaWidth - lineLegendWidth + ) + + const currentSlopeWidth = endX - startX + if (currentSlopeWidth > maxSlopeWidth) { + const padding = currentSlopeWidth - maxSlopeWidth + startX += padding / 2 + endX -= padding / 2 + } + + return [startX, endX] } - @computed get backgroundGroups() { - return this.slopeData.filter((group) => !group.isHovered) + @computed get lineLegendX(): number { + return this.xRange[1] + LINE_LEGEND_PADDING } - @computed get foregroundGroups() { - return this.slopeData.filter((group) => !!group.isHovered) + // 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, + } + }) } private playIntroAnimation() { @@ -516,22 +526,68 @@ export class SlopeChart .attr("stroke-dashoffset", "0%") } - @computed get renderUid(): number { - return guid() + componentDidMount() { + exposeInstanceOnWindow(this) + + if (!this.manager.disableIntroAnimation) { + this.playIntroAnimation() + } } - @computed get tooltip(): React.ReactElement | undefined { - const { - tooltipState: { target, position, fading }, - } = this + private hoverTimer?: NodeJS.Timeout + @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { + clearTimeout(this.hoverTimer) + this.hoveredSeriesName = seriesName + } - const { series } = target || {} - if (!series) return + @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) + } - const { isRelativeMode } = this.manager, - timeRange = [this.startTime, this.endTime] - .map((t) => this.formatColumn.formatTime(t)) - .join(" to "), + @action.bound onSlopeMouseOver(series: SlopeChartSeries) { + this.hoveredSeriesName = series.seriesName + this.tooltipState.target = { series } + } + + @action.bound onSlopeMouseLeave() { + this.hoveredSeriesName = undefined + this.tooltipState.target = null + } + + mouseFrame?: number + @action.bound onMouseMove( + ev: React.MouseEvent | React.TouchEvent + ) { + this.updateTooltipPosition(ev) + this.detectHoveredSlope(ev) + } + + @action.bound onMouseLeave() { + if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) + + this.onSlopeMouseLeave() + } + + @computed get renderUid(): number { + return guid() + } + + @computed get tooltip(): React.ReactElement | undefined { + const { + tooltipState: { target, position, fading }, + } = this + + const { series } = target || {} + if (!series) return + + const { isRelativeMode } = this.manager, + timeRange = [this.startTime, this.endTime] + .map((t) => this.formatColumn.formatTime(t)) + .join(" to "), timeLabel = timeRange + (isRelativeMode ? " (relative change)" : "") const columns = this.yColumns @@ -578,406 +634,260 @@ export class SlopeChart > v.y)} + values={[series.startValue, series.endValue]} /> ) } - render() { - if (this.failMessage) - return ( - - ) - - const { manager } = this.props - - return ( - - {this.renderLabelledSlopes()} - {manager.showLegend && } - {this.showNoDataSection && this.noDataSection} - {this.tooltip} - + private renderNoDataSection(): React.ReactElement { + const seriesNames = this.noDataSeries.map((series) => series.seriesName) + const bounds = new Bounds( + this.bounds.right - this.sidebarWidth, + this.bounds.top, + this.sidebarWidth, + this.bounds.height ) - } - @computed get failMessage() { - const message = getDefaultFailMessage(this.manager) - if (message) return message - else if (isEmpty(this.series)) return "No matching data" - return "" - } - - defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines - - @computed private get yColumns(): CoreColumn[] { - return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) - } - - @computed protected get yColumnSlugs(): ColumnSlug[] { - return autoDetectYColumnSlugs(this.manager) - } - - @computed get transformedTableFromGrapher(): OwidTable { return ( - this.manager.transformedTable ?? - this.transformTable(this.inputTable) + ) } - @computed get transformedTable(): OwidTable { - 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) { - table = table.toTotalGrowthForEachColumnComparedToStartTime( - startHandleTimeBound, - this.yColumnSlugs ?? [] - ) - } - return table - } - - @computed get inputTable() { - return this.manager.table + private renderSlope( + series: PlacedSlopeChartSeries, + mode?: RenderMode + ): React.ReactElement { + return } - componentDidMount() { - exposeInstanceOnWindow(this) - - if (!this.manager.disableIntroAnimation) { - this.playIntroAnimation() + private renderSlopes() { + if (!this.isFocusModeActive) { + return this.placedSeries.map((series) => this.renderSlope(series)) } - } - @computed private get colorScheme(): ColorScheme { - return ( - (this.manager.baseColorScheme - ? ColorSchemes.get(this.manager.baseColorScheme) - : null) ?? ColorSchemes.get(this.defaultBaseColorScheme) + const [focusedSeries, backgroundSeries] = partition( + this.placedSeries, + (series) => series.seriesName === this.hoveredSeriesName ) - } - - @computed private get startTime(): Time { - return this.transformedTable.minTime - } - - @computed private get endTime(): Time { - return this.transformedTable.maxTime! // TODO: remove the ! when we have a better way to handle missing maxTime - } - - @computed get seriesStrategy(): SeriesStrategy { - return autoDetectSeriesStrategy(this.manager, true) - } - - @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, - }) - } - - 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 - } - } - - // 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) - } - - private 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 - } - - 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 - } - } - - @computed get series() { - const { startTime, endTime } = this - const totalEntityCount = - this.transformedTable.availableEntityNames.length - return this.yColumns.flatMap((column) => - column.uniqEntityNames - .map((entityName) => { - const seriesName = this.getSeriesName( - entityName, - column.displayName || "Missing name", - totalEntityCount - ) - - const values: SlopeChartValue[] = [] - const yValues = - column.valueByEntityNameAndOriginalTime.get( - entityName - )! || [] - - yValues.forEach((value, time) => { - if (time !== startTime && time !== endTime) return - - values.push({ - x: time, - y: value, - }) - }) - - // sort values by time - const sortedValues = sortBy(values, (v) => v.x) - - const color = this.categoricalColorAssigner.assign( - this.getColorKey( - entityName, - column.displayName, - totalEntityCount - ) - ) - - const annotation = this.getAnnotationsForSeries(seriesName) - - return { - seriesName, - color, - values: sortedValues, - annotation, - } as SlopeChartSeries - }) - .filter((series) => series.values.length >= 2) + return ( + <> + {backgroundSeries.map((series) => + this.renderSlope(series, RenderMode.mute) + )} + {focusedSeries.map((series) => + this.renderSlope(series, RenderMode.focus) + )} + ) } - @observable private hoverTimer?: NodeJS.Timeout - - @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { - clearTimeout(this.hoverTimer) - this.hoverKey = seriesName - } - - @action.bound clearHighlightedSeries(): void { - clearTimeout(this.hoverTimer) - this.hoverTimer = setTimeout(() => { - // wait before clearing selection in case the mouse is moving quickly over neighboring labels - this.hoverKey = undefined - }, 200) - } - - @action.bound onLineLegendMouseLeave(): void { - this.clearHighlightedSeries() - } - - @computed private get yAxisConfig(): AxisConfig { - return new AxisConfig(this.manager.yAxisConfig, this) - } - - @computed private get allValues() { - return this.series.flatMap((g) => g.values) - } + private renderChartArea() { + const { bounds, xDomain, yRange, startX, endX } = this - @computed private get yScaleType() { - return this.yAxisConfig.scaleType || ScaleType.linear - } + const [bottom, top] = yRange - @computed private get yDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.y), - this.yScaleType || ScaleType.linear + return ( + + + + + + + + + {this.renderSlopes()} + + ) } - @computed private get yDomain(): [number, number] { - const domain = this.yAxisConfig.domain || [Infinity, -Infinity] - const domainDefault = this.yDomainDefault - return [ - Math.min(domain[0], domainDefault[0]), - Math.max(domain[1], domainDefault[1]), - ] - } - - @computed get yRange(): [number, number] { - return this.bounds - .padTop(TOP_PADDING) - .padBottom(BOTTOM_PADDING) - .yRange() - } - - @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 yAxisWidth(): number { - return this.yAxis.width + 5 // 5px account for the tick marks - } - - @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 xScale(): ScaleLinear { - const { xDomain, xRange } = this - return scaleLinear().domain(xDomain).range(xRange) - } - - @computed private get xDomain(): [number, number] { - return this.xDomainDefault - } + render() { + if (this.failMessage) + return ( + + ) - @computed private get xDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.x), - ScaleType.linear + return ( + + {this.renderChartArea()} + {this.manager.showLegend && } + {this.showNoDataSection && this.renderNoDataSection()} + {this.tooltip} + ) } - - @computed get lineLegendX(): number { - return this.bounds.right - 240 - } - - @computed get labelSeries(): LineLabelSeries[] { - return this.series.map((series) => { - const { seriesName, color, values, annotation } = series - return { - color, - seriesName, - label: seriesName, - annotation, - yValue: values[1].y, - } - }) - } } -@observer -class SlopeEntry extends React.Component { - line: SVGElement | null = null - - @computed get isInBackground() { - const { isLayerMode, isHovered } = this.props - - if (!isLayerMode) return false - - return !isHovered - } - - render() { - const { x1, y1, x2, y2, color, isHovered, seriesName } = this.props - const { isInBackground } = this +interface SlopeProps { + series: PlacedSlopeChartSeries + color: string + mode?: RenderMode + onMouseOver?: (series: SlopeChartSeries) => void + onMouseLeave?: () => void +} - const lineColor = isInBackground ? "#e2e2e2" : color - const opacity = isHovered ? 1 : 0.5 - const lineStrokeWidth = isHovered ? 4 : 2 +function Slope({ + series, + color, + mode = RenderMode.default, + onMouseOver, + onMouseLeave, +}: SlopeProps) { + const { seriesName, startPoint, endPoint } = series + + const usedColor = { + [RenderMode.default]: color, + [RenderMode.focus]: color, + [RenderMode.mute]: "#e2e2e2", + [RenderMode.background]: "#e2e2e2", + }[mode] + + return ( + onMouseOver?.(series)} + onMouseLeave={() => onMouseLeave?.()} + > + + + + + ) +} - const showDots = isHovered +interface GridLinesProps { + bounds: Bounds + yAxis: VerticalAxis + startX: number + endX: number +} - return ( - - (this.line = el)} - x1={x1} - y1={y1} - x2={x2} - y2={y2} - stroke={lineColor} - strokeWidth={lineStrokeWidth} - opacity={opacity} - /> - {showDots && ( - <> - + {yAxis.tickLabels.map((tick) => { + const y = yAxis.place(tick.value) + return ( + + {/* grid lines connecting the chart area to the axis */} + - - - )} - - ) - } + + ) + })} + + ) +} + +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 9b27bf3110b..290259fd0f0 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts @@ -1,51 +1,20 @@ -import { CoreColumn } from "@ourworldindata/core-table" +import { 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" - -export interface SlopeChartValue { - x: number - y: number -} export interface SlopeChartSeries extends ChartSeries { - size: number - values: SlopeChartValue[] + startValue: number + endValue: number annotation?: string } -export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" - -export interface SlopeEntryProps extends ChartSeries { - series: SlopeChartSeries - - x1: number - y1: number - x2: number - y2: number +export type RawSlopeChartSeries = PartialBy< + SlopeChartSeries, + "startValue" | "endValue" +> - isLayerMode: boolean - isHovered: boolean +export interface PlacedSlopeChartSeries extends SlopeChartSeries { + startPoint: PointVector + endPoint: PointVector } -export interface LabelledSlopesProps { - manager: ChartManager - formatColumn: CoreColumn - bounds: Bounds - seriesArr: SlopeChartSeries[] - hoverKey?: string - onMouseOver: (slopeProps: SlopeEntryProps) => void - onMouseLeave: () => void - onClick?: () => void - isPortrait: boolean -} - -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 857a08a1b4a..96bb76f8625 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 (