From b59dbcaddf7a708eccbe1cafd82b2001b06b7dc4 Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Wed, 11 Dec 2024 13:59:32 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(slope)=20support=20facetting=20?= =?UTF-8?q?/=20TAS-734=20(#4247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds facetting support for slope charts. If there are many facets it could be nice to declutter them, but I don't have time for that now. --- .../grapher/src/chart/ChartManager.ts | 3 +- .../grapher/src/controls/SettingsMenu.tsx | 1 + .../grapher/src/facetChart/FacetChart.tsx | 6 +- .../grapher/src/lineLegend/LineLegend.tsx | 11 + .../src/scatterCharts/ScatterPlotChart.tsx | 14 +- .../grapher/src/slopeCharts/SlopeChart.tsx | 210 +++++++++++++----- packages/@ourworldindata/utils/src/Util.ts | 6 +- 7 files changed, 186 insertions(+), 65 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts index d00e2912578..8b24bdd70a3 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts @@ -68,6 +68,7 @@ export interface ChartManager { hidePoints?: boolean // for line options startHandleTimeBound?: TimeBound // for relative-to-first-year line chart + hideNoDataSection?: boolean // for slope charts // we need endTime so DiscreteBarCharts and StackedDiscreteBarCharts can // know what date the timeline is set to. and let's pass startTime in, too. @@ -78,7 +79,7 @@ export interface ChartManager { seriesStrategy?: SeriesStrategy sortConfig?: SortConfig - showNoDataArea?: boolean + showNoDataArea?: boolean // No data area in Marimekko charts externalLegendHoverBin?: ColorScaleBin | undefined disableIntroAnimation?: boolean diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx index fb7f4351ce7..961863e4d64 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx @@ -195,6 +195,7 @@ export class SettingsMenu extends React.Component<{ StackedBar, StackedDiscreteBar, LineChart, + SlopeChart, ].includes(this.chartType as any) const hasProjection = filledDimensions.some( diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index ee2870aac03..cc760503661 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -295,7 +295,9 @@ export class FacetChart return series.map((series, index) => { const { bounds } = gridBoundsArr[index] const showLegend = !this.hideFacetLegends + const hidePoints = true + const hideNoDataSection = true // NOTE: The order of overrides is important! // We need to preserve most config coming in. @@ -319,6 +321,7 @@ export class FacetChart endTime, missingDataStrategy, backgroundColor, + hideNoDataSection, ...series.manager, xAxisConfig: { ...globalXAxisConfig, @@ -756,7 +759,8 @@ export class FacetChart ) if (this.facetStrategy === FacetStrategy.metric && newBins.length <= 1) return [] - return newBins + const sortedBins = sortBy(newBins, (bin) => bin.label) + return sortedBins } @observable.ref private legendHoverBin: ColorScaleBin | undefined = diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 857b7ce8e8e..ff46f8e451f 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -355,6 +355,11 @@ export class LineLegend extends React.Component { return test.stableWidth } + static width(props: LineLegendProps): number { + const test = new LineLegend(props) + return test.width + } + static fontSize(props: Partial): number { const test = new LineLegend(props as LineLegendProps) return test.fontSize @@ -449,6 +454,12 @@ export class LineLegend extends React.Component { return this.maxLabelWidth + DEFAULT_CONNECTOR_LINE_WIDTH + MARKER_MARGIN } + @computed get width(): number { + return this.needsLines + ? this.stableWidth + : this.maxLabelWidth + MARKER_MARGIN + } + @computed get onMouseOver(): any { return this.props.onMouseOver ?? noop } diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 5906adeb482..108272297f4 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -1144,10 +1144,16 @@ export class ScatterPlotChart // domains across the entire timeline private domainDefault(property: "x" | "y"): [number, number] { const scaleType = property === "x" ? this.xScaleType : this.yScaleType - return domainExtent( - this.pointsForAxisDomains.map((point) => point[property]), - scaleType, - this.manager.zoomToSelection && this.selectedPoints.length ? 1.1 : 1 + const defaultDomain: [number, number] = + scaleType === ScaleType.log ? [1, 100] : [-1, 1] + return ( + domainExtent( + this.pointsForAxisDomains.map((point) => point[property]), + scaleType, + this.manager.zoomToSelection && this.selectedPoints.length + ? 1.1 + : 1 + ) ?? defaultDomain ) } diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 3d695836021..0044ce18374 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -22,6 +22,7 @@ import { BASE_FONT_SIZE, GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, + GRAPHER_FONT_SCALE_12, } from "../core/GrapherConstants" import { ScaleType, @@ -34,6 +35,7 @@ import { EntityName, RenderMode, VerticalAlign, + FacetStrategy, } from "@ourworldindata/types" import { ChartInterface } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" @@ -81,6 +83,8 @@ import { } from "../lineCharts/LineChartHelpers" import { SelectionArray } from "../selection/SelectionArray" import { Halo } from "@ourworldindata/components" +import { HorizontalColorLegendManager } from "../horizontalColorLegend/HorizontalColorLegends" +import { CategoricalBin } from "../color/ColorScaleBin" type SVGMouseOrTouchEvent = | React.MouseEvent @@ -89,10 +93,10 @@ type SVGMouseOrTouchEvent = export interface SlopeChartManager extends ChartManager { canSelectMultipleEntities?: boolean // used to pick an appropriate series name hasTimeline?: boolean // used to filter the table for the entity selector + hideNoDataSection?: boolean } const TOP_PADDING = 6 // leave room for overflowing dots -const BOTTOM_PADDING = 20 // leave room for the x-axis const LINE_LEGEND_PADDING = 4 @@ -194,7 +198,10 @@ export class SlopeChart } @computed private get innerBounds(): Bounds { - return this.bounds.padRight(this.sidebarWidth + this.sidebarMargin) + return this.bounds + .padTop(TOP_PADDING) + .padBottom(this.bottomPadding) + .padRight(this.sidebarWidth + this.sidebarMargin) } @computed get fontSize(): number { @@ -226,7 +233,7 @@ export class SlopeChart } @computed private get isFocusModeActive(): boolean { - return this.hoveredSeriesName !== undefined + return this.focusedSeriesNames.length > 0 } @computed private get yColumns(): CoreColumn[] { @@ -265,6 +272,17 @@ export class SlopeChart return autoDetectSeriesStrategy(this.manager, true) } + @computed get availableFacetStrategies(): FacetStrategy[] { + const strategies: FacetStrategy[] = [FacetStrategy.none] + + if (this.selectionArray.numSelectedEntities > 1) + strategies.push(FacetStrategy.entity) + + if (this.yColumns.length > 1) strategies.push(FacetStrategy.metric) + + return strategies + } + @computed private get categoricalColorAssigner(): CategoricalColorAssigner { return new CategoricalColorAssigner({ colorScheme: this.colorScheme, @@ -431,6 +449,8 @@ export class SlopeChart } @computed private get showNoDataSection(): boolean { + if (this.manager.hideNoDataSection) return false + // nothing to show if there are no series with missing data if (this.noDataSeries.length === 0) return false @@ -464,7 +484,8 @@ export class SlopeChart } @computed private get yDomainDefault(): [number, number] { - return domainExtent(this.allYValues, this.yScaleType) + const defaultDomain: [number, number] = [Infinity, -Infinity] + return domainExtent(this.allYValues, this.yScaleType) ?? defaultDomain } @computed private get yDomain(): [number, number] { @@ -476,11 +497,16 @@ export class SlopeChart ] } + @computed private get bottomPadding(): number { + return 1.5 * GRAPHER_FONT_SCALE_12 * this.fontSize + } + + @computed private get xLabelPadding(): number { + return this.useCompactLayout ? 4 : 8 + } + @computed private get yRange(): [number, number] { - return this.bounds - .padTop(TOP_PADDING) - .padBottom(BOTTOM_PADDING) - .yRange() + return this.innerBounds.yRange() } @computed get yAxis(): VerticalAxis { @@ -511,6 +537,22 @@ export class SlopeChart : 0 } + @computed get externalLegend(): HorizontalColorLegendManager | undefined { + if (!this.manager.showLegend) { + const categoricalLegendData = this.series.map( + (series, index) => + new CategoricalBin({ + index, + value: series.seriesName, + label: series.seriesName, + color: series.color, + }) + ) + return { categoricalLegendData } + } + return undefined + } + @computed get maxLineLegendWidth(): number { return 0.25 * this.innerBounds.width } @@ -525,7 +567,7 @@ export class SlopeChart const bottom = this.bounds.bottom - // leave space for the x-axis labels - BOTTOM_PADDING + + this.bottomPadding + // but allow for a little extra space this.lineLegendFontSize / 2 @@ -586,16 +628,25 @@ export class SlopeChart } @computed get lineLegendWidthLeft(): number { - if (!this.manager.showLegend) return 0 - return LineLegend.stableWidth({ + const props = { labelSeries: this.lineLegendSeriesLeft, ...this.lineLegendPropsCommon, ...this.lineLegendPropsLeft, - }) + } + + // We usually use the "stable" width of the line legend, which might be + // a bit too wide because the connector line width is always added, even + // it no connector lines are drawn. Using the stable width prevents + // layout shifts when the connector lines are toggled on and off. + // However, if the chart area is very narrow (like when it's faceted), + // the stable width of the line legend takes too much space, so we use the + // actual width instead. + return this.isNarrow + ? LineLegend.width(props) + : LineLegend.stableWidth(props) } - @computed get lineLegendRight(): LineLegend | undefined { - if (!this.manager.showLegend) return undefined + @computed get lineLegendRight(): LineLegend { return new LineLegend({ labelSeries: this.lineLegendSeriesRight, ...this.lineLegendPropsCommon, @@ -604,7 +655,16 @@ export class SlopeChart } @computed get lineLegendWidthRight(): number { - return this.lineLegendRight?.stableWidth ?? 0 + // We usually use the "stable" width of the line legend, which might be + // a bit too wide because the connector line width is always added, even + // it no connector lines are drawn. Using the stable width prevents + // layout shifts when the connector lines are toggled on and off. + // However, if the chart area is very narrow (like when it's faceted), + // the stable width of the line legend takes too much space, so we use the + // actual width instead. + return this.isNarrow + ? this.lineLegendRight.width + : this.lineLegendRight.stableWidth } @computed get visibleLineLegendLabelsRight(): Set { @@ -642,7 +702,7 @@ export class SlopeChart const maxEndX = this.innerBounds.right - lineLegendWidthRight // use all available space if the chart is narrow - if (this.manager.isNarrow) { + if (this.manager.isNarrow || this.isNarrow) { return [minStartX, maxEndX] } @@ -674,12 +734,33 @@ export class SlopeChart return [startX, endX] } + @computed private get isNarrow(): boolean { + return this.bounds.width < 320 + } + @computed get useCompactLayout(): boolean { - return !!this.manager.isSemiNarrow + return !!this.manager.isSemiNarrow || this.isNarrow } @computed get focusedSeriesNames(): SeriesName[] { - return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] + const focusedSeriesNames: SeriesName[] = [] + + // hovered series name + if (this.hoveredSeriesName) + focusedSeriesNames.push(this.hoveredSeriesName) + + // hovered legend item in the external facet legend + if (this.manager.externalLegendHoverBin) { + focusedSeriesNames.push( + ...this.series + .map((s) => s.seriesName) + .filter((name) => + this.manager.externalLegendHoverBin?.contains(name) + ) + ) + } + + return focusedSeriesNames } private constructSingleLineLegendSeries( @@ -723,7 +804,10 @@ export class SlopeChart this.constructSingleLineLegendSeries( series, (series) => series.end.value, - { showSeriesName: true, showAnnotation: !this.useCompactLayout } + { + showSeriesName: this.manager.showLegend, + showAnnotation: !this.useCompactLayout, + } ) ) } @@ -748,7 +832,7 @@ export class SlopeChart } @computed private get showSeriesNamesInLineLegendLeft(): boolean { - return this.lineLegendMaxLevelLeft >= 4 + return this.lineLegendMaxLevelLeft >= 4 && !!this.manager.showLegend } private updateTooltipPosition(event: SVGMouseOrTouchEvent): void { @@ -827,22 +911,13 @@ export class SlopeChart this.onSlopeMouseLeave() } - private failMessageForSingleTimeSelection = - "Two time points needed for comparison" @computed get failMessage(): string { const message = getDefaultFailMessage(this.manager) if (message) return message - else if (this.startTime === this.endTime) - return this.failMessageForSingleTimeSelection + else if (this.series.length === 0) return "No matching data" return "" } - @computed get helpMessage(): string | undefined { - if (this.failMessage === this.failMessageForSingleTimeSelection) - return "Click or drag the timeline to select two different points in time." - return undefined - } - @computed get renderUid(): number { return guid() } @@ -1013,7 +1088,7 @@ export class SlopeChart const [focusedSeries, backgroundSeries] = partition( this.placedSeries, - (series) => series.seriesName === this.hoveredSeriesName + (series) => this.focusedSeriesNames.includes(series.seriesName) ) return ( @@ -1032,33 +1107,53 @@ export class SlopeChart ) } - private renderChartArea(): React.ReactElement { - const { bounds, xDomain, yRange, startX, endX } = this + private renderYAxis(): React.ReactElement { + return ( + <> + {!this.yAxis.hideGridlines && ( + + )} + {!this.yAxis.hideAxis && ( + + )} + + ) + } - const [bottom, top] = yRange + private renderXAxis() { + const { xDomain, startX, endX } = this return ( - - - + <> + + ) + } + + private renderChartArea() { + return ( + + {this.renderYAxis()} + {this.renderXAxis()} {this.renderSlopes()} @@ -1133,8 +1228,6 @@ export class SlopeChart } private renderLineLegends(): React.ReactElement | void { - if (!this.manager.showLegend) return - return ( <> {this.renderLineLegendLeft()} @@ -1146,12 +1239,14 @@ export class SlopeChart render() { if (this.failMessage) return ( - + <> + {this.renderYAxis()} + + ) return ( @@ -1304,12 +1399,14 @@ function MarkX({ x, top, bottom, + labelPadding, fontSize, }: { label: string x: number top: number bottom: number + labelPadding: number fontSize: number }) { return ( @@ -1317,7 +1414,8 @@ function MarkX({ { +): [number, number] | undefined => { const filterValues = scaleType === ScaleType.log ? numValues.filter((v) => v > 0) : numValues const [minValue, maxValue] = extent(filterValues) @@ -439,9 +439,9 @@ export const domainExtent = ( ? [minValue / 10, minValue * 10] : [minValue - 1, maxValue + 1] } - } else { - return scaleType === ScaleType.log ? [1, 100] : [-1, 1] } + + return undefined } // Compound annual growth rate