From 16f0fdf5a9d59443ad9e2b86224a06e6e48172c5 Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Tue, 1 Oct 2024 07:08:56 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(line=20chart)=20optimise=20static?= =?UTF-8?q?=20version=20for=20Figma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grapher/src/axis/AxisViews.tsx | 144 +++---- .../src/captionedChart/CaptionedChart.tsx | 1 + .../grapher/src/core/grapher.scss | 19 - .../grapher/src/lineCharts/LineChart.tsx | 380 ++++++++++++------ .../src/lineCharts/LineChartConstants.ts | 3 + .../grapher/src/lineLegend/LineLegend.tsx | 271 ++++++++----- packages/@ourworldindata/utils/src/Util.ts | 13 +- 7 files changed, 483 insertions(+), 348 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx b/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx index 96c220c8253..accd08d7494 100644 --- a/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx +++ b/packages/@ourworldindata/grapher/src/axis/AxisViews.tsx @@ -31,6 +31,7 @@ export class VerticalAxisGridLines extends React.Component<{ verticalAxis: VerticalAxis bounds: Bounds strokeWidth?: number + dashPattern?: string }> { render(): React.ReactElement { const { bounds, verticalAxis, strokeWidth } = this.props @@ -48,12 +49,14 @@ export class VerticalAxisGridLines extends React.Component<{ : t.solid ? SOLID_TICK_COLOR : TICK_COLOR + const dasharray = + this.props.dashPattern ?? + dasharrayFromFontSize(verticalAxis.tickFontSize) return ( ) })} @@ -82,6 +79,7 @@ export class HorizontalAxisGridLines extends React.Component<{ horizontalAxis: HorizontalAxis bounds?: Bounds strokeWidth?: number + dashPattern?: string }> { @computed get bounds(): Bounds { return this.props.bounds ?? DEFAULT_BOUNDS @@ -104,12 +102,14 @@ export class HorizontalAxisGridLines extends React.Component<{ : t.solid ? SOLID_TICK_COLOR : TICK_COLOR + const dasharray = + this.props.dashPattern ?? + dasharrayFromFontSize(horizontalAxis.tickFontSize) return ( ) })} @@ -189,6 +183,7 @@ interface DualAxisViewProps { labelColor?: string tickColor?: string lineWidth?: number + gridDashPattern?: string detailsMarker?: DetailsMarker } @@ -201,6 +196,7 @@ export class DualAxisComponent extends React.Component { labelColor, tickColor, lineWidth, + gridDashPattern, detailsMarker, } = this.props const { bounds, horizontalAxis, verticalAxis, innerBounds } = dualAxis @@ -210,6 +206,7 @@ export class DualAxisComponent extends React.Component { verticalAxis={verticalAxis} bounds={innerBounds} strokeWidth={lineWidth} + dashPattern={gridDashPattern} /> ) @@ -218,6 +215,7 @@ export class DualAxisComponent extends React.Component { horizontalAxis={horizontalAxis} bounds={innerBounds} strokeWidth={lineWidth} + dashPattern={gridDashPattern} /> ) @@ -245,15 +243,12 @@ export class DualAxisComponent extends React.Component { ) return ( - + <> {horizontalAxisComponent} {verticalAxisComponent} {verticalGridlines} {horizontalGridlines} - + ) } } @@ -303,6 +298,9 @@ export class VerticalAxisComponent extends React.Component<{ {tickLabels.map((label, i) => ( { - const { x, xAlign, formattedValue } = label - return ( - + {tickLabels.map((label) => ( + - {showTickMarks && ( - + label.formattedValue )} - {showTickLabels && ( - - {formattedValue} - + x1={axis.place(label.value)} + y1={tickMarksYPosition - tickMarkWidth / 2} + x2={axis.place(label.value)} + y2={tickMarksYPosition + tickSize} + stroke={SOLID_TICK_COLOR} + strokeWidth={tickMarkWidth} + /> + ))} + + )} + {showTickLabels && ( + + {tickLabels.map((label) => ( + - ) - })} + fontSize={axis.tickFontSize} + > + {label.formattedValue} + + ))} + + )} ) } } -export class HorizontalAxisTickMark extends React.Component<{ - tickMarkTopPosition: number - tickMarkXPosition: number - color: string - width?: number - id?: string -}> { - render(): React.ReactElement { - const { tickMarkTopPosition, tickMarkXPosition, color, width, id } = - this.props - const tickSize = 5 - const tickBottom = tickMarkTopPosition + tickSize - return ( - - ) - } -} - export class VerticalAxisTickMark extends React.Component<{ tickMarkLeftPosition: number tickMarkYPosition: number diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index 83d7520637c..981a4b0a0ea 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -572,6 +572,7 @@ export class StaticCaptionedChart extends CaptionedChart { {this.fonts} {this.patterns} { @computed private get backgroundLines(): PlacedLineChartSeries[] { const { focusedSeriesNames } = this.props + // if nothing is focused, everything is focused, so nothing is in the background + if (!focusedSeriesNames.length) return [] return this.props.placedSeries.filter( (series) => !focusedSeriesNames.includes(series.seriesName) ) @@ -166,95 +168,161 @@ class Lines extends React.Component { return this.props.lineOutlineWidth ?? DEFAULT_LINE_OUTLINE_WIDTH } - private renderFocusGroups(): React.ReactElement[] { - return this.focusedLines.map((series, index) => { - // If the series only contains one point, then we will always want to show a marker/circle - // because we can't draw a line. - const showMarkers = - (this.hasMarkers || series.placedPoints.length === 1) && - !series.isProjection - const strokeDasharray = series.isProjection ? "2,3" : undefined + private renderPathForSeries( + series: PlacedLineChartSeries, + props: Partial> + ): React.ReactElement { + const strokeDasharray = series.isProjection ? "2,3" : undefined + return ( + [value.x, value.y]) as [ + number, + number, + ][] + )} + /> + ) + } - return ( - - {/* - Create a white outline around the lines so they're - easier to follow when they overlap. - */} - [ - value.x, - value.y, - ]) as [number, number][] - )} - /> - - {showMarkers && ( - - {series.placedPoints.map((value, index) => ( - + {this.focusedLines.map((series) => { + const strokeDasharray = series.isProjection + ? "2,3" + : undefined + return ( + + {this.renderPathForSeries(series, { + id: makeIdForHumanConsumption( + "outline", + series.seriesName + ), + stroke: BACKGROUND_COLOR, + strokeWidth: + this.strokeWidth + + this.lineOutlineWidth * 2, + })} + {this.props.multiColor ? ( + - ))} - - )} - - ) - }) + ) : ( + this.renderPathForSeries(series, { + id: makeIdForHumanConsumption( + "line", + series.seriesName + ), + stroke: + series.placedPoints[0]?.color ?? + DEFAULT_LINE_COLOR, + }) + )} + + ) + })} + + ) } - private renderBackgroundGroups(): React.ReactElement[] { - return this.backgroundLines.map((series, index) => ( - - [ - value.x, - value.y, - ]) as [number, number][] - )} - fill="none" - strokeWidth={1} - /> + private renderLineMarkers(): React.ReactElement | void { + const { horizontalAxis } = this.props.dualAxis + if (this.focusedLines.length === 0) return + return ( + + {this.focusedLines.map((series) => { + // If the series only contains one point, then we will always want to show a marker/circle + // because we can't draw a line. + const showMarkers = + (this.hasMarkers || series.placedPoints.length === 1) && + !series.isProjection + return ( + showMarkers && ( + + {series.placedPoints.map((value, index) => ( + + ))} + + ) + ) + })} - )) + ) } - render(): React.ReactElement { - const { bounds } = this + private renderFocusGroups(): React.ReactElement | void { + return ( + <> + {this.renderFocusLines()} + {this.renderLineMarkers()} + + ) + } + private renderBackgroundGroups(): React.ReactElement | void { + if (this.backgroundLines.length === 0) return return ( - + + {this.backgroundLines.map((series) => + this.renderPathForSeries(series, { + id: makeIdForHumanConsumption( + "background-line", + series.seriesName + ), + stroke: "#ddd", + strokeWidth: 1, + }) + )} + + ) + } + + renderStatic(): React.ReactElement { + return ( + <> + {this.renderBackgroundGroups()} + {this.renderFocusGroups()} + + ) + } + + renderInteractive(): React.ReactElement { + const { bounds } = this + return ( + { ) } + + render(): React.ReactElement { + return this.props.isStatic + ? this.renderStatic() + : this.renderInteractive() + } } @observer @@ -341,6 +415,10 @@ export class LineChart return this.manager.table } + @computed get isStatic(): boolean { + return this.manager.isStatic ?? false + } + @computed private get transformedTableFromGrapher(): OwidTable { return ( this.manager.transformedTable ?? @@ -449,8 +527,6 @@ export class LineChart } @computed private get markerRadius(): number { - // hide markers but don't remove them from the DOM - if (this.manager.isStaticAndSmall) return 0 return this.hasColorScale ? VARIABLE_COLOR_MARKER_RADIUS : DEFAULT_MARKER_RADIUS @@ -772,43 +848,77 @@ export class LineChart return strategies } - render(): React.ReactElement { - const { manager, tooltip, dualAxis, clipPath, activeXVerticalLine } = - this + renderDualAxis(): React.ReactElement { + const { manager, dualAxis } = this - const comparisonLines = manager.comparisonLines || [] + const lineWidth = manager.isStaticAndSmall + ? GRAPHER_AXIS_LINE_WIDTH_THICK + : GRAPHER_AXIS_LINE_WIDTH_DEFAULT + const dashPattern = manager.isStaticAndSmall ? "7, 7" : undefined - const dualAxisComponent = ( + return ( ) + } - if (this.failMessage) - return ( - - {dualAxisComponent} - + } + + /** + * Render the lines themselves, their labels, and comparison lines if given + */ + renderChartElements(): React.ReactElement { + const { manager } = this + const { comparisonLines = [] } = manager + return ( + <> + {comparisonLines.map((line, index) => ( + - - ) + ))} + {manager.showLegend && } + + + ) + } - // The tiny bit of extra space in the clippath is to ensure circles centered on the very edge are still fully visible + renderStatic(): React.ReactElement { + return ( + <> + {this.renderColorLegend()} + {this.renderDualAxis()} + {this.renderChartElements()} + + ) + } + + renderInteractive(): React.ReactElement { return ( - {clipPath.element} + {/* The tiny bit of extra space in the clippath is to ensure circles + centered on the very edge are still fully visible */} + {this.clipPath.element} - {/* This ensures that the parent is big enough such that we get mouse hover events for the - whole charting area, including the axis, the entity labels, and the whitespace next to them. - We need these to be able to show the tooltip for the first/last year even if the mouse is outside the charting area. */} + {/* This ensures that the parent is big enough such that + we get mouse hover events for the whole charting area, including + the axis, the entity labels, and the whitespace next to them. + We need these to be able to show the tooltip for the first/last + year even if the mouse is outside the charting area. */} - {this.hasColorLegend && ( - - )} - {dualAxisComponent} - - {comparisonLines.map((line, index) => ( - - ))} - {manager.showLegend && } - - - {activeXVerticalLine} + {this.renderColorLegend()} + {this.renderDualAxis()} + {this.renderChartElements()} - {tooltip} + {this.activeXVerticalLine} + {this.tooltip} ) } + render(): React.ReactElement { + const { manager, dualAxis } = this + + if (this.failMessage) + return ( + + {this.renderDualAxis()} + + + ) + + return manager.isStatic ? this.renderStatic() : this.renderInteractive() + } + @computed get failMessage(): string { const message = getDefaultFailMessage(this.manager) if (message) return message @@ -1154,6 +1265,7 @@ export class LineChart ...series, placedPoints: series.points.map( (point): PlacedPoint => ({ + time: point.x, x: round(horizontalAxis.place(point.x), 1), y: round(verticalAxis.place(point.y), 1), color: this.hasColorScale diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts index 58afdeecfc7..0adb09fb480 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChartConstants.ts @@ -14,6 +14,7 @@ export interface PlacedPoint { x: number y: number color: Color + time: number } export interface LineChartSeries extends ChartSeries { @@ -33,6 +34,8 @@ export interface LinesProps { lineStrokeWidth?: number lineOutlineWidth?: number markerRadius?: number + isStatic?: boolean + multiColor?: boolean } export interface LineChartManager extends ChartManager { diff --git a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx index 39b3e1da829..2a432777229 100644 --- a/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx +++ b/packages/@ourworldindata/grapher/src/lineLegend/LineLegend.tsx @@ -30,6 +30,8 @@ const MARKER_MARGIN = 4 // Space between the label and the annotation const ANNOTATION_PADDING = 2 +const LEFT_PADDING = 35 + const DEFAULT_FONT_WEIGHT = 400 export interface LineLabelSeries extends ChartSeries { @@ -55,6 +57,14 @@ interface PlacedSeries extends SizedSeries { midY: number } +function getSeriesKey( + series: PlacedSeries, + index: number, + key: string +): string { + return `${key}-${index}-` + series.seriesName +} + function groupBounds(group: PlacedSeries[]): Bounds { const first = group[0] const last = group[group.length - 1] @@ -77,90 +87,153 @@ function stackGroupVertically( } @observer -class Label extends React.Component<{ - series: PlacedSeries - manager: LineLegend +class LineLabels extends React.Component<{ + series: PlacedSeries[] + key: string + needsLines: boolean isFocus?: boolean - needsLines?: boolean - onMouseOver: () => void - onClick: () => void - onMouseLeave?: () => void + isStatic?: boolean + onClick?: (series: PlacedSeries) => void + onMouseOver?: (series: PlacedSeries) => void + onMouseLeave?: (series: PlacedSeries) => void }> { - render(): React.ReactElement { - const { - series, - manager, - isFocus, - needsLines, - onMouseOver, - onMouseLeave, - onClick, - } = this.props - const x = series.origBounds.x - const markerX1 = x + MARKER_MARGIN - const markerX2 = x + manager.leftPadding - MARKER_MARGIN - const step = (markerX2 - markerX1) / (series.totalLevels + 1) - const markerXMid = markerX1 + step + series.level * step - const lineColor = isFocus ? "#999" : "#eee" - const textColor = isFocus ? darkenColorForText(series.color) : "#ddd" - const annotationColor = isFocus ? "#333" : "#ddd" + @computed get markers(): { + series: PlacedSeries + labelText: { x: number; y: number } + connectorLine: { x1: number; x2: number } + }[] { + return this.props.series.map((series) => { + const { x } = series.origBounds + const connectorLine = { + x1: x + MARKER_MARGIN, + x2: x + LEFT_PADDING - MARKER_MARGIN, + } + + const textX = this.props.needsLines + ? connectorLine.x2 + MARKER_MARGIN + : x + MARKER_MARGIN + const textY = series.bounds.y + + return { + series, + labelText: { x: textX, y: textY }, + connectorLine, + } + }) + } + + @computed get textLabels(): React.ReactElement { return ( - - {needsLines && ( - - - - )} - - {series.textWrap.render( - needsLines ? markerX2 + MARKER_MARGIN : markerX1, - series.bounds.y, - { - id: makeIdForHumanConsumption("label"), + + {this.markers.map(({ series, labelText }) => { + const textColor = this.props.isFocus + ? darkenColorForText(series.color) + : "#ddd" + return series.textWrap.render(labelText.x, labelText.y, { textProps: { fill: textColor }, - } - )} - {series.annotationTextWrap && - series.annotationTextWrap.render( - needsLines ? markerX2 + MARKER_MARGIN : markerX1, - series.bounds.y + - series.textWrap.height + - ANNOTATION_PADDING, + }) + })} + + ) + } + + @computed get textAnnotations(): React.ReactElement | void { + const markersWithAnnotations = this.markers.filter( + ({ series }) => series.annotationTextWrap !== undefined + ) + if (!markersWithAnnotations) return + return ( + + {markersWithAnnotations.map(({ series, labelText }) => { + const annotationColor = this.props.isFocus ? "#333" : "#ddd" + return series.annotationTextWrap?.render( + labelText.x, + labelText.y + series.textWrap.height, { - id: makeIdForHumanConsumption("annotation"), textProps: { fill: annotationColor, - className: "textAnnotation", style: { fontWeight: 300 }, }, } - )} + ) + })} ) } + + @computed get connectorLines(): React.ReactElement | void { + if (!this.props.needsLines) return + return ( + + {this.markers.map(({ series, connectorLine }, index) => { + const { isFocus } = this.props + const { x1, x2 } = connectorLine + const { + level, + totalLevels, + origBounds: { centerY: leftCenterY }, + bounds: { centerY: rightCenterY }, + } = series + + const step = (x2 - x1) / (totalLevels + 1) + const markerXMid = x1 + step + level * step + const d = `M${x1},${leftCenterY} H${markerXMid} V${rightCenterY} H${x2}` + const lineColor = isFocus ? "#999" : "#eee" + + return ( + + ) + })} + + ) + } + + @computed get interactions(): React.ReactElement | void { + return ( + + {this.props.series.map((series, index) => { + return ( + this.props.onMouseOver?.(series)} + onMouseLeave={() => + this.props.onMouseLeave?.(series) + } + onClick={() => this.props.onClick?.(series)} + style={{ cursor: "default" }} + > + + + ) + })} + + ) + } + + render(): React.ReactElement { + return ( + <> + {this.connectorLines} + {this.textAnnotations} + {this.textLabels} + {!this.props.isStatic && this.interactions} + + ) + } } export interface LineLegendManager { @@ -177,14 +250,13 @@ export interface LineLegendManager { lineLegendX?: number // used to determine which series should be labelled when there is limited space seriesSortedByImportance?: EntityName[] + isStatic?: boolean } @observer export class LineLegend extends React.Component<{ manager: LineLegendManager }> { - leftPadding = 35 - @computed private get fontSize(): number { return GRAPHER_FONT_SCALE_12 * (this.manager.fontSize ?? BASE_FONT_SIZE) } @@ -198,8 +270,8 @@ export class LineLegend extends React.Component<{ } @computed.struct get sizedLabels(): SizedSeries[] { - const { fontSize, fontWeight, leftPadding, maxWidth } = this - const maxTextWidth = maxWidth - leftPadding + const { fontSize, fontWeight, maxWidth } = this + const maxTextWidth = maxWidth - LEFT_PADDING const maxAnnotationWidth = Math.min(maxTextWidth, 150) return this.manager.labelSeries.map((label) => { @@ -223,7 +295,7 @@ export class LineLegend extends React.Component<{ textWrap, annotationTextWrap, width: - leftPadding + + LEFT_PADDING + Math.max( textWrap.width, annotationTextWrap ? annotationTextWrap.width : 0 @@ -557,33 +629,40 @@ export class LineLegend extends React.Component<{ return this.placedSeries.some((series) => series.totalLevels > 1) } - private renderBackground(): React.ReactElement[] { - return this.backgroundSeries.map((series, index) => ( -