From bae405820ce248dbc9491590a3cfda10cd454561 Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Fri, 25 Oct 2024 13:11:18 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(grapher)=20show=20tooltip=20at=20t?= =?UTF-8?q?he=20bottom=20on=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/captionedChart/CaptionedChart.tsx | 1 + .../grapher/src/chart/ChartManager.ts | 3 +- .../grapher/src/chart/ChartTypeMap.tsx | 3 +- .../src/controls/EntitySelectionToggle.tsx | 2 + .../grapher/src/core/Grapher.tsx | 74 +- .../grapher/src/facetChart/FacetChart.tsx | 3 +- .../grapher/src/lineCharts/LineChart.tsx | 32 +- .../grapher/src/mapCharts/MapChart.tsx | 18 +- .../grapher/src/mapCharts/MapSparkline.tsx | 255 ++++++ .../grapher/src/mapCharts/MapTooltip.scss | 72 +- .../grapher/src/mapCharts/MapTooltip.tsx | 231 +---- .../src/scatterCharts/ScatterPlotChart.tsx | 1 + .../src/stackedCharts/MarimekkoChart.tsx | 1 + .../src/stackedCharts/StackedAreaChart.tsx | 33 +- .../src/stackedCharts/StackedBarChart.tsx | 1 + .../stackedCharts/StackedDiscreteBarChart.tsx | 1 + .../src/timeline/TimelineComponent.tsx | 2 + .../src/timeline/TimelineController.ts | 1 + .../grapher/src/tooltip/Tooltip.scss | 813 +++++++++--------- .../grapher/src/tooltip/Tooltip.tsx | 178 ++-- .../grapher/src/tooltip/TooltipContents.tsx | 83 +- .../grapher/src/tooltip/TooltipProps.ts | 16 +- .../types/src/grapherTypes/GrapherTypes.ts | 7 + packages/@ourworldindata/types/src/index.ts | 1 + 24 files changed, 1081 insertions(+), 751 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index 9aaffebffd6..629381a7bb5 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -229,6 +229,7 @@ export class CaptionedChart extends React.Component { bounds={bounds} manager={manager} containerElement={this.containerElement} + framePaddingHorizontal={this.framePaddingHorizontal} /> ) } diff --git a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts index bdd16eecc06..d2aa146dd79 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts @@ -39,7 +39,7 @@ export interface ChartManager { isRelativeMode?: boolean comparisonLines?: ComparisonLineConfig[] showLegend?: boolean - tooltips?: TooltipManager["tooltips"] + tooltip?: TooltipManager["tooltip"] baseColorScheme?: ColorSchemeName invertColorScheme?: boolean compareEndPointsOnly?: boolean @@ -99,6 +99,7 @@ export interface ChartManager { isExportingForSocialMedia?: boolean secondaryColorInStaticCharts?: string backgroundColor?: Color + shouldPinTooltipToBottom?: boolean detailsOrderedByReference?: string[] detailsMarkerInSvg?: DetailsMarker diff --git a/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx b/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx index 45a0eef3b5f..2fbb5f8576c 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx @@ -16,7 +16,8 @@ import { MarimekkoChart } from "../stackedCharts/MarimekkoChart" interface ChartComponentProps { manager: ChartManager bounds?: Bounds - containerElement?: any // todo: remove? + containerElement?: HTMLDivElement + framePaddingHorizontal?: number } interface ChartComponentClass extends ComponentClass { diff --git a/packages/@ourworldindata/grapher/src/controls/EntitySelectionToggle.tsx b/packages/@ourworldindata/grapher/src/controls/EntitySelectionToggle.tsx index 3df6fa62294..80201e0eef0 100644 --- a/packages/@ourworldindata/grapher/src/controls/EntitySelectionToggle.tsx +++ b/packages/@ourworldindata/grapher/src/controls/EntitySelectionToggle.tsx @@ -18,6 +18,7 @@ export interface EntitySelectionManager { isEntitySelectorModalOrDrawerOpen?: boolean isOnChartTab?: boolean hideEntityControls?: boolean + onEntitySelectorOpen?: () => void } interface EntitySelectionLabel { @@ -82,6 +83,7 @@ export class EntitySelectionToggle extends React.Component<{ onClick={(e): void => { this.props.manager.isEntitySelectorModalOrDrawerOpen = !active + this.props.manager.onEntitySelectorOpen?.() e.stopPropagation() }} type="button" diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index b4d70bf4ebf..b26bbb2037e 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -66,6 +66,7 @@ import { sortBy, extractDetailsFromSyntax, omit, + isTouchDevice, } from "@ourworldindata/utils" import { MarkdownTextWrap, @@ -105,6 +106,7 @@ import { GrapherWindowType, MultiDimDataPageProps, Color, + GrapherTooltipAnchor, } from "@ourworldindata/types" import { BlankOwidTable, @@ -210,6 +212,7 @@ import { type EntitySelectorState, } from "../entitySelector/EntitySelector" import { SlideInDrawer } from "../slideInDrawer/SlideInDrawer" +import { BodyDiv } from "../bodyDiv/BodyDiv" declare global { interface Window { @@ -854,7 +857,7 @@ export class Grapher @observable.ref isExportingToSvgOrPng = false @observable.ref isSocialMediaExport = false - tooltips?: TooltipManager["tooltips"] = observable.map({}, { deep: false }) + tooltip?: TooltipManager["tooltip"] = observable.box(undefined) @observable.ref isPlaying = false @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished @@ -2177,6 +2180,10 @@ export class Grapher return isMobile() } + @computed get isTouchDevice(): boolean { + return isTouchDevice() + } + @computed private get externalBounds(): Bounds { return this.props.bounds ?? DEFAULT_BOUNDS } @@ -2812,12 +2819,21 @@ export class Grapher - {/* tooltip */} - + {/* tooltip: either pin to the bottom or render into the chart area */} + {this.shouldPinTooltipToBottom ? ( + + + + ) : ( + + )} ) } @@ -2825,14 +2841,26 @@ export class Grapher // Chart should only render SVG when it's on the screen @action.bound private setUpIntersectionObserver(): void { if (typeof window !== "undefined" && "IntersectionObserver" in window) { - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - this.hasBeenVisible = true - observer.disconnect() - } - }) - }) + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.hasBeenVisible = true + } + + // dismiss tooltip when less than 2/3 of the chart is visible + const tooltip = this.tooltip?.get() + const isNotVisible = !entry.isIntersecting + const isPartiallyVisible = + entry.isIntersecting && + entry.intersectionRatio < 0.66 + if (tooltip && (isNotVisible || isPartiallyVisible)) { + tooltip.dismiss?.() + } + }) + }, + { threshold: [0, 0.66] } + ) observer.observe(this.containerElement!) this.disposers.push(() => observer.disconnect()) } else { @@ -2893,7 +2921,7 @@ export class Grapher @computed get isNarrow(): boolean { if (this.isStatic) return false - return this.frameBounds.width <= 400 + return this.frameBounds.width <= 420 } // SemiNarrow charts shorten their button labels to fit within the controls row @@ -2946,6 +2974,10 @@ export class Grapher : GRAPHER_BACKGROUND_DEFAULT } + @computed get shouldPinTooltipToBottom(): boolean { + return this.isNarrow && this.isTouchDevice + } + // Binds chart properties to global window title and URL. This should only // ever be invoked from top-level JavaScript. private bindToWindow(): void { @@ -3277,6 +3309,11 @@ export class Grapher timelineController = new TimelineController(this) + @action.bound onTimelineClick(): void { + const tooltip = this.tooltip?.get() + if (tooltip) tooltip.dismiss?.() + } + // todo: restore this behavior?? onStartPlayOrDrag(): void { this.debounceMode = true @@ -3417,6 +3454,11 @@ export class Grapher ) } + @action.bound onEntitySelectorOpen(): void { + const tooltip = this.tooltip?.get() + if (tooltip) tooltip.dismiss?.() + } + // This is just a helper method to return the correct table for providing entity choices. We want to // provide the root table, not the transformed table. // A user may have added time or other filters that would filter out all rows from certain entities, but diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 3983634dbc6..e26732d7058 100644 --- a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx +++ b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx @@ -502,7 +502,8 @@ export class FacetChart ...series.manager.yAxisConfig, ...axes.y.config, }, - tooltips: this.manager.tooltips, + tooltip: this.manager.tooltip, + shouldPinTooltipToBottom: this.manager.shouldPinTooltipToBottom, base: this.manager.base, } const contentBounds = getContentBounds( diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index a7713038b1f..728a1b3f257 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -443,8 +443,21 @@ export class LineChart return table } - @action.bound private onCursorLeave(): void { + @action.bound private dismissTooltip(): void { this.tooltipState.target = null + } + + @action.bound onClick(e: React.MouseEvent): void { + // don't fire document event handler that dismisses the tooltip + if (this.manager.shouldPinTooltipToBottom) { + e.stopPropagation() + } + } + + @action.bound private onCursorLeave(): void { + if (!this.manager.shouldPinTooltipToBottom) { + this.dismissTooltip() + } this.clearHighlightedSeries() } @@ -600,6 +613,14 @@ export class LineChart ) } + @computed private get tooltipId(): number { + return this.renderUid + } + + @computed private get isTooltipActive(): boolean { + return this.manager.tooltip?.get()?.id === this.tooltipId + } + @computed private get tooltip(): React.ReactElement | undefined { const { formatColumn, colorColumn, hasColorScale } = this const { target, position, fading } = this.tooltipState @@ -669,7 +690,7 @@ export class LineChart return ( {this.renderChartElements()} - {this.activeXVerticalLine} + {this.isTooltipActive && this.activeXVerticalLine} {this.tooltip} ) diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index f5d7014bbd6..89a84ca3ea6 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -41,7 +41,11 @@ import { } from "./MapChartConstants" import { MapConfig } from "./MapConfig" import { ColorScale, ColorScaleManager } from "../color/ColorScale" -import { BASE_FONT_SIZE, Patterns } from "../core/GrapherConstants" +import { + BASE_FONT_SIZE, + DEFAULT_GRAPHER_FRAME_PADDING, + Patterns, +} from "../core/GrapherConstants" import { ChartInterface } from "../chart/ChartInterface" import { CategoricalBin, @@ -76,6 +80,7 @@ interface MapChartProps { bounds?: Bounds manager: MapChartManager containerElement?: HTMLDivElement + framePaddingHorizontal?: number } // Get the underlying geographical topology elements we're going to display @@ -229,6 +234,12 @@ export class MapChart return this.seriesMap } + @computed private get framePaddingHorizontal(): number { + return ( + this.props.framePaddingHorizontal ?? DEFAULT_GRAPHER_FRAME_PADDING + ) + } + base: React.RefObject = React.createRef() @action.bound onMapMouseOver(feature: GeoFeature): void { const series = @@ -601,6 +612,10 @@ export class MapChart const { tooltipState } = this + const sparklineWidth = this.manager.shouldPinTooltipToBottom + ? this.bounds.width + (this.framePaddingHorizontal - 1) * 2 + : undefined + return ( )} diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx new file mode 100644 index 00000000000..509b6c23213 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx @@ -0,0 +1,255 @@ +import React from "react" +import { computed } from "mobx" +import { observer } from "mobx-react" +import { + AxisConfigInterface, + ColumnSlug, + EntityName, + OwidVariableRoundingMode, + OwidVariableRow, + Time, +} from "@ourworldindata/types" +import { OwidTable } from "@ourworldindata/core-table" +import { LineChart } from "../lineCharts/LineChart" +import { + Bounds, + isNumber, + checkIsVeryShortUnit, + isString, + first, + last, + min, + max, +} from "@ourworldindata/utils" +import { LineChartManager } from "../lineCharts/LineChartConstants" +import { ColorScale } from "../color/ColorScale.js" + +const DEFAULT_SPARKLINE_WIDTH = 250 +const DEFAULT_SPARKLINE_HEIGHT = 87 + +const SPARKLINE_PADDING = 15 +const SPARKLINE_NUDGE = 3 // step away from the axis + +export interface MapSparklineManager { + mapColumnSlug?: ColumnSlug + timeSeriesTable: OwidTable + targetTime?: Time + entityName: EntityName + lineColorScale?: ColorScale + datum?: OwidVariableRow + mapAndYColumnAreTheSame?: boolean + yAxisConfig?: AxisConfigInterface +} + +@observer +export class MapSparkline extends React.Component<{ + manager: MapSparklineManager + sparklineWidth?: number + sparklineHeight?: number +}> { + static shouldShow(manager: MapSparklineManager): boolean { + const test = new MapSparkline({ manager }) + return test.showSparkline + } + + @computed private get manager(): MapSparklineManager { + return this.props.manager + } + + @computed private get mapColumnSlug(): ColumnSlug | undefined { + return this.manager.mapColumnSlug + } + + @computed private get sparklineTable(): OwidTable { + if (this.mapColumnSlug === undefined) return new OwidTable() + + return this.manager.timeSeriesTable + .filterByEntityNames([this.manager.entityName]) + .columnFilter( + this.mapColumnSlug, + isNumber, + "Drop rows with non-number values in Y column" + ) + .sortBy([this.manager.timeSeriesTable.timeColumn.slug]) + } + + @computed private get hasTimeSeriesData(): boolean { + return this.sparklineTable !== undefined + ? this.sparklineTable.numRows > 1 + : false + } + + @computed private get showSparkline(): boolean { + return this.hasTimeSeriesData + } + + @computed private get sparklineManager(): LineChartManager { + // use the whole time range for the sparkline, not just the range where this series has data + let { minTime, maxTime } = this.manager.timeSeriesTable ?? {} + if (this.mapColumnSlug) { + const times = + this.manager.timeSeriesTable.getTimesUniqSortedAscForColumns([ + this.mapColumnSlug, + ]) + minTime = first(times) ?? minTime + maxTime = last(times) ?? maxTime + } + + // Pass down short units, while omitting long or undefined ones. + const unit = this.sparklineTable.get(this.mapColumnSlug).shortUnit + const yAxisUnit = + typeof unit === "string" + ? checkIsVeryShortUnit(unit) + ? unit + : "" + : "" + + return { + table: this.sparklineTable, + transformedTable: this.sparklineTable, + yColumnSlug: this.mapColumnSlug, + colorColumnSlug: this.mapColumnSlug, + selection: [this.manager.entityName], + colorScaleOverride: this.manager.lineColorScale, + showLegend: false, + hidePoints: true, + fontSize: 11, + disableIntroAnimation: true, + lineStrokeWidth: 2, + annotation: { + entityName: this.manager.entityName, + year: this.manager.datum?.time, + }, + yAxisConfig: { + hideAxis: true, + hideGridlines: false, + tickFormattingOptions: { + unit: yAxisUnit, + numberAbbreviation: "short", + }, + // Copy min/max from top-level Grapher config if Y column == Map column + min: this.manager.mapAndYColumnAreTheSame + ? this.manager.yAxisConfig?.min + : undefined, + max: this.manager.mapAndYColumnAreTheSame + ? this.manager.yAxisConfig?.max + : undefined, + ticks: [ + // Show minimum and zero (maximum is added by hand in render so it's never omitted) + { value: -Infinity, priority: 2 }, + { value: 0, priority: 1 }, + ], + }, + xAxisConfig: { + hideAxis: false, + hideGridlines: true, + tickFormattingOptions: {}, + min: minTime ?? this.manager.targetTime, + max: maxTime ?? this.manager.targetTime, + ticks: [ + // Show minimum and maximum + { value: -Infinity, priority: 1 }, + { value: Infinity, priority: 1 }, + ], + }, + } + } + + @computed private get sparklineWidth(): number { + return this.props.sparklineWidth ?? DEFAULT_SPARKLINE_WIDTH + } + + @computed private get sparklineHeight(): number { + return this.props.sparklineHeight ?? DEFAULT_SPARKLINE_HEIGHT + } + + @computed private get sparklineBounds(): Bounds { + // Add padding so that the edges of the plot doesn't get clipped. + // The plot can go out of boundaries due to line stroke thickness & labels. + return new Bounds(0, 0, this.sparklineWidth, this.sparklineHeight).pad({ + top: 9, + left: SPARKLINE_PADDING, + right: SPARKLINE_PADDING, + bottom: 3, + }) + } + + @computed private get sparklineChart(): LineChart { + return new LineChart({ + manager: this.sparklineManager, + bounds: this.sparklineBounds, + }) + } + + render(): React.ReactElement | null { + if (!this.showSparkline) return null + + const { yAxisConfig } = this.sparklineManager, + yColumn = this.sparklineTable.get(this.mapColumnSlug), + minVal = min([yColumn.min, yAxisConfig?.min]), + maxVal = max([yColumn.max, yAxisConfig?.max]), + minCustom = + isNumber(minVal) && + this.manager.lineColorScale?.getBinForValue(minVal)?.label, + maxCustom = + isNumber(maxVal) && + this.manager.lineColorScale?.getBinForValue(maxVal)?.label, + useCustom = isString(minCustom) && isString(maxCustom), + minLabel = useCustom + ? minCustom + : yColumn.formatValueShort(minVal ?? 0, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, + }), + maxLabel = useCustom + ? maxCustom + : yColumn.formatValueShort(maxVal ?? 0, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, + }) + const { innerBounds: axisBounds } = this.sparklineChart.dualAxis + + const labelX = axisBounds.right - SPARKLINE_NUDGE + const labelTop = axisBounds.top - SPARKLINE_NUDGE + const labelBottom = axisBounds.bottom - SPARKLINE_NUDGE + + return ( +
+ + + + {maxLabel !== minLabel && ( + + + {maxLabel} + + + )} + + + {minLabel} + + + {minLabel} + + + +
+ ) + } +} diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.scss b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.scss index 4ddaad6d2c5..f8b7731c558 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.scss +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.scss @@ -1,46 +1,48 @@ -.sparkline { - padding-bottom: 4px; +@at-root { + .sparkline { + padding-bottom: 4px; - .HorizontalAxis text { - fill: #787878; - font-weight: 700; - font-size: 10px; - transform: translate(2px, 2px); + .HorizontalAxis text { + fill: #787878; + font-weight: 700; + font-size: 10px; + transform: translate(2px, 2px); - &:last-child { - transform: translate(-2px, 2px); + &:last-child { + transform: translate(-2px, 2px); + } } - } - // min-line + zero-line - .AxisGridLines.horizontalLines { - line { - stroke-dasharray: 0; - stroke-linecap: square; - stroke: $gray-30; - } + // min-line + zero-line + .AxisGridLines.horizontalLines { + line { + stroke-dasharray: 0; + stroke-linecap: square; + stroke: $gray-30; + } - // the zero line - line:nth-child(2) { - stroke: #787878; + // the zero line + line:nth-child(2) { + stroke: #787878; + } } - } - line.max-line { - stroke-linecap: square; - stroke: $gray-30; - } + line.max-line { + stroke-linecap: square; + stroke: $gray-30; + } - .axis-label { - text { - font: 400 10px Lato; - letter-spacing: 0.01em; - font-style: italic; - text-anchor: end; - fill: #787878; - &.outline { - stroke: rgba(255, 255, 255, 0.85); - stroke-width: 2px; + .axis-label { + text { + font: 400 10px Lato; + letter-spacing: 0.01em; + font-style: italic; + text-anchor: end; + fill: #787878; + &.outline { + stroke: rgba(255, 255, 255, 0.85); + stroke-width: 2px; + } } } } diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx index a09d6f07969..93433eb8b93 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx @@ -15,26 +15,18 @@ import { Time, EntityName, OwidVariableRow, - OwidVariableRoundingMode, + AxisConfigInterface, } from "@ourworldindata/types" import { CoreColumn, OwidTable } from "@ourworldindata/core-table" -import { LineChart } from "../lineCharts/LineChart" import { - Bounds, isNumber, AllKeysRequired, - checkIsVeryShortUnit, PrimitiveType, - isString, - first, - last, - min, - max, excludeUndefined, anyToString, } from "@ourworldindata/utils" -import { LineChartManager } from "../lineCharts/LineChartConstants" import { darkenColorForHighContrastText } from "../color/ColorUtils" +import { MapSparkline, MapSparklineManager } from "./MapSparkline.js" interface MapTooltipProps { tooltipState: TooltipState<{ featureId: string; clickable: boolean }> @@ -43,16 +35,16 @@ interface MapTooltipProps { formatValueIfCustom: (d: PrimitiveType) => string | undefined timeSeriesTable: OwidTable targetTime?: Time + sparklineWidth?: number + sparklineHeight?: number } -const SPARKLINE_WIDTH = 250 -const SPARKLINE_HEIGHT = 87 -const SPARKLINE_PADDING = 15 -const SPARKLINE_NUDGE = 3 // step away from the axis - @observer -export class MapTooltip extends React.Component { - @computed private get mapColumnSlug(): string | undefined { +export class MapTooltip + extends React.Component + implements MapSparklineManager +{ + @computed get mapColumnSlug(): string | undefined { return this.props.manager.mapColumnSlug } @@ -60,49 +52,37 @@ export class MapTooltip extends React.Component { return this.mapTable.get(this.mapColumnSlug) } - @computed private get mapAndYColumnAreTheSame(): boolean { + @computed get mapAndYColumnAreTheSame(): boolean { const { yColumnSlug, yColumnSlugs, mapColumnSlug } = this.props.manager return yColumnSlugs && mapColumnSlug !== undefined ? yColumnSlugs.includes(mapColumnSlug) : yColumnSlug === mapColumnSlug } - @computed private get entityName(): EntityName { + @computed get entityName(): EntityName { return this.props.tooltipState.target?.featureId ?? "" } - // Table pre-filtered by targetTime, exlcudes time series + @computed get targetTime(): Time | undefined { + return this.props.targetTime + } + + // Table pre-filtered by targetTime, excludes time series @computed private get mapTable(): OwidTable { const table = this.props.manager.transformedTable ?? this.props.manager.table return table.filterByEntityNames([this.entityName]) } - @computed private get timeSeriesTable(): OwidTable | undefined { - if (this.mapColumnSlug === undefined) return undefined + @computed get timeSeriesTable(): OwidTable { return this.props.timeSeriesTable - .filterByEntityNames([this.entityName]) - .columnFilter( - this.mapColumnSlug, - isNumber, - "Drop rows with non-number values in Y column" - ) - .sortBy([this.props.timeSeriesTable.timeColumn.slug]) } - @computed private get datum(): - | OwidVariableRow - | undefined { + @computed get datum(): OwidVariableRow | undefined { return this.mapColumn.owidRows[0] } - @computed private get hasTimeSeriesData(): boolean { - return this.timeSeriesTable !== undefined - ? this.timeSeriesTable.numRows > 1 - : false - } - - @computed private get lineColorScale(): ColorScale { + @computed get lineColorScale(): ColorScale { const oldManager = this.props.colorScaleManager // Make sure all ColorScaleManager props are included. // We can't ...rest here because I think mobx computeds aren't @@ -118,101 +98,11 @@ export class MapTooltip extends React.Component { } @computed private get showSparkline(): boolean { - return this.hasTimeSeriesData - } - - // Line chart fields - @computed private get sparklineTable(): OwidTable { - return this.timeSeriesTable ?? new OwidTable() - } - @computed private get sparklineManager(): LineChartManager { - // use the whole time range for the sparkline, not just the range where this series has data - let { minTime, maxTime } = this.props.timeSeriesTable ?? {} - if (this.mapColumnSlug) { - const times = - this.props.timeSeriesTable.getTimesUniqSortedAscForColumns([ - this.mapColumnSlug, - ]) - minTime = first(times) ?? minTime - maxTime = last(times) ?? maxTime - } - - // Pass down short units, while omitting long or undefined ones. - const unit = this.sparklineTable.get(this.mapColumnSlug).shortUnit - const yAxisUnit = - typeof unit === "string" - ? checkIsVeryShortUnit(unit) - ? unit - : "" - : "" - - return { - table: this.sparklineTable, - transformedTable: this.sparklineTable, - yColumnSlug: this.mapColumnSlug, - colorColumnSlug: this.mapColumnSlug, - selection: [this.entityName], - colorScaleOverride: this.lineColorScale, - showLegend: false, - hidePoints: true, - fontSize: 11, - disableIntroAnimation: true, - lineStrokeWidth: 2, - annotation: { - entityName: this.entityName, - year: this.datum?.time, - }, - yAxisConfig: { - hideAxis: true, - hideGridlines: false, - tickFormattingOptions: { - unit: yAxisUnit, - numberAbbreviation: "short", - }, - // Copy min/max from top-level Grapher config if Y column == Map column - min: this.mapAndYColumnAreTheSame - ? this.props.manager.yAxisConfig?.min - : undefined, - max: this.mapAndYColumnAreTheSame - ? this.props.manager.yAxisConfig?.max - : undefined, - ticks: [ - // Show minimum and zero (maximum is added by hand in render so it's never omitted) - { value: -Infinity, priority: 2 }, - { value: 0, priority: 1 }, - ], - }, - xAxisConfig: { - hideAxis: false, - hideGridlines: true, - tickFormattingOptions: {}, - min: minTime ?? this.props.targetTime, - max: maxTime ?? this.props.targetTime, - ticks: [ - // Show minimum and maximum - { value: -Infinity, priority: 1 }, - { value: Infinity, priority: 1 }, - ], - }, - } + return MapSparkline.shouldShow(this) } - @computed private get sparklineBounds(): Bounds { - // Add padding so that the edges of the plot doesn't get clipped. - // The plot can go out of boundaries due to line stroke thickness & labels. - return new Bounds(0, 0, SPARKLINE_WIDTH, SPARKLINE_HEIGHT).pad({ - top: 9, - left: SPARKLINE_PADDING, - right: SPARKLINE_PADDING, - bottom: 3, - }) - } - - @computed private get sparklineChart(): LineChart { - return new LineChart({ - manager: this.sparklineManager, - bounds: this.sparklineBounds, - }) + @computed get yAxisConfig(): AxisConfigInterface | undefined { + return this.props.manager.yAxisConfig } render(): React.ReactElement { @@ -250,28 +140,7 @@ export class MapTooltip extends React.Component { } } - const { yAxisConfig } = this.sparklineManager, - yColumn = this.sparklineTable.get(this.mapColumnSlug), - minVal = min([yColumn.min, yAxisConfig?.min]), - maxVal = max([yColumn.max, yAxisConfig?.max]), - minCustom = - isNumber(minVal) && - this.lineColorScale.getBinForValue(minVal)?.label, - maxCustom = - isNumber(maxVal) && - this.lineColorScale.getBinForValue(maxVal)?.label, - useCustom = isString(minCustom) && isString(maxCustom), - minLabel = useCustom - ? minCustom - : yColumn.formatValueShort(minVal ?? 0, { - roundingMode: OwidVariableRoundingMode.decimalPlaces, - }), - maxLabel = useCustom - ? maxCustom - : yColumn.formatValueShort(maxVal ?? 0, { - roundingMode: OwidVariableRoundingMode.decimalPlaces, - }) - const { innerBounds: axisBounds } = this.sparklineChart.dualAxis + const yColumn = this.mapTable.get(this.mapColumnSlug) const targetNotice = datum && datum.time !== targetTime ? displayTime : undefined @@ -295,10 +164,6 @@ export class MapTooltip extends React.Component { : undefined const footer = excludeUndefined([toleranceNotice, roundingNotice]) - const labelX = axisBounds.right - SPARKLINE_NUDGE - const labelTop = axisBounds.top - SPARKLINE_NUDGE - const labelBottom = axisBounds.bottom - SPARKLINE_NUDGE - return ( { subtitleFormat={targetNotice ? "notice" : undefined} footer={footer} dissolve={fading} + dismiss={() => (this.props.tooltipState.target = null)} > { roundingNotice.icon !== TooltipFooterIcon.none } /> - {this.showSparkline && ( -
- - - - {maxLabel !== minLabel && ( - - - {maxLabel} - - - )} - - - {minLabel} - - - {minLabel} - - - -
- )} +
) } diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 1f70d50a869..12ad62c83c6 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -983,6 +983,7 @@ export class ScatterPlotChart subtitle={timeLabel} dissolve={fading} footer={footer} + dismiss={() => (this.tooltipState.target = null)} > (this.tooltipState.target = null)} > {yValues.map(({ name, value, notice }) => ( ): void { + // don't fire document event handler that dismisses the tooltip + if (this.manager.shouldPinTooltipToBottom) { + e.stopPropagation() + } + } + @action.bound private onCursorMove( ev: React.MouseEvent | React.TouchEvent ): void { @@ -457,9 +464,15 @@ export class StackedAreaChart } } + @action.bound private dismissTooltip(): void { + this.tooltipState.target = null + } + @action.bound private onCursorLeave(): void { + if (!this.manager.shouldPinTooltipToBottom) { + this.dismissTooltip() + } this.hoverSeriesName = undefined - this.tooltipState.target = null } @computed private get activeXVerticalLine(): @@ -468,7 +481,6 @@ export class StackedAreaChart const { dualAxis, series } = this const { horizontalAxis, verticalAxis } = dualAxis const hoveredPointIndex = this.tooltipState.target?.index - if (hoveredPointIndex === undefined) return undefined return ( @@ -505,6 +517,14 @@ export class StackedAreaChart ) } + @computed private get tooltipId(): number { + return this.renderUid + } + + @computed private get isTooltipActive(): boolean { + return this.manager.tooltip?.get()?.id === this.tooltipId + } + @computed private get tooltip(): React.ReactElement | undefined { const { target, position, fading } = this.tooltipState if (!target) return undefined @@ -533,7 +553,7 @@ export class StackedAreaChart return ( - {this.activeXVerticalLine} + {this.isTooltipActive && this.activeXVerticalLine} {this.tooltip} ) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 8815c60b367..3c34dc23610 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -393,6 +393,7 @@ export class StackedBarChart subtitleFormat="unit" footer={footer} dissolve={fading} + dismiss={() => (this.tooltipState.target = null)} > (this.tooltipState.target = null)} > void + onTimelineClick?: () => void } const delay = (ms: number): Promise => diff --git a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss index e414ea9b8ce..0359f682f95 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss +++ b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.scss @@ -1,467 +1,496 @@ -.tooltip-container > .Tooltip { - $background-fill: #f0f0f0; - $background-stroke: $gray-20; - $dark-grey: #2d2d2d; - $light-grey: $gray-30; - $grey: #787878; - $red: #cc3b55; - $green: #2c8465; - $fade-time: 200ms; - $fade-delay: 200ms; - $medium: 400; - $bold: 700; - - border-radius: 4px; - border: 1px solid $background-stroke; - box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.2); - background: white; - text-align: left; - position: absolute; - pointer-events: none; - font-family: $sans-serif-font-stack; - font-size: 16px; - - @mixin diagonal-background($background, $color) { - background: repeating-linear-gradient( - -45deg, - $background, - $background 16%, - $color 16%, - $color 25% - ); - } - - .frontmatter { - background: $background-fill; - color: black; - padding: 8px 12px; - border-radius: 3px 3px 0 0; - - .title, - .subtitle { - margin: 0; - padding: 0; - line-height: 1.125em; +@at-root { + .tooltip-container { + $background-fill: #f0f0f0; + $background-stroke: $gray-20; + $dark-grey: #2d2d2d; + $light-grey: $gray-30; + $grey: #787878; + $red: #cc3b55; + $green: #2c8465; + $fade-time: 200ms; + $fade-delay: 200ms; + $medium: 400; + $bold: 700; + + &.fixed-bottom { + position: fixed; + left: 4px; + bottom: 4px; + width: calc(100% - 8px); + z-index: $zindex-tooltip; + + > .Tooltip { + pointer-events: auto; + + .content-and-endmatter { + max-height: min(166px, 25vh); + overflow-y: auto; + overflow-x: hidden; + } + } } - .title { - font-size: 14px; - font-weight: $bold; - letter-spacing: 0; - } - .subtitle { - margin: 4px 0 2px 0; - font-size: 12px; - font-weight: $medium; - letter-spacing: 0.01em; + > .Tooltip { + border-radius: 4px; + border: 1px solid $background-stroke; + box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.2); + background: white; + text-align: left; + pointer-events: none; + font-family: $sans-serif-font-stack; + font-size: 16px; + + @mixin diagonal-background($background, $color) { + background: repeating-linear-gradient( + -45deg, + $background, + $background 16%, + $color 16%, + $color 25% + ); + } - svg.fa-circle-info { - color: $grey; - margin-right: 0.5em; + .frontmatter { + background: $background-fill; + color: black; + padding: 8px 12px; + border-radius: 3px 3px 0 0; + + .title, + .subtitle { + margin: 0; + padding: 0; + line-height: 1.125em; + } + + .title { + font-size: 14px; + font-weight: $bold; + letter-spacing: 0; + } + .subtitle { + margin: 4px 0 2px 0; + font-size: 12px; + font-weight: $medium; + letter-spacing: 0.01em; + + svg.fa-circle-info { + color: $grey; + margin-right: 0.5em; + } + } } - } - } - &.plain header { - border-radius: 3px; - background: white; - } + &.plain header { + border-radius: 3px; + background: white; + } - .content { - padding: 8px 12px; + .content { + padding: 8px 12px; - > p { - margin: 0; - padding: 0; - } + > p { + margin: 0; + padding: 0; + } + + .variable { + .definition { + color: $gray-70; + font-size: 12px; + line-height: 15px; + letter-spacing: 0.01em; + font-weight: $bold; + + .name { + margin-right: 0.25em; + } - .variable { - .definition { - color: $gray-70; - font-size: 12px; - line-height: 15px; - letter-spacing: 0.01em; - font-weight: $bold; + .unit { + font-weight: $medium; + font-style: normal; - .name { - margin-right: 0.25em; + &::before { + content: "("; + } + + &::after { + content: ")"; + } + } + } + + .values { + display: flex; + align-items: baseline; + justify-content: space-between; + color: $dark-grey; + padding: 2px 0; + line-height: 21px; + font-size: 18px; + font-weight: $bold; + + .range { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.2em; + flex-grow: 1; + + .term { + overflow-wrap: anywhere; + } + + svg.arrow { + height: 14px; + padding-right: 0.15em; + &.up path { + fill: $green; + } + &.down path { + fill: $red; + } + &.right path { + fill: $grey; + } + } + } + + .time-notice { + position: relative; + font-weight: $medium; + font-size: 14px; + line-height: 21px; + padding-left: 20px; + svg.fa-circle-info { + position: absolute; + top: 3px; + left: 0; + } + color: $grey; + } + } + } + + .variable + .variable { + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid $light-grey; } - .unit { + table.series-list { + color: $dark-grey; + font-size: 14px; + line-height: 22px; font-weight: $medium; - font-style: normal; + white-space: normal; + border-collapse: collapse; + width: 100%; + + // only used if rows have ≥2 values + thead { + font-size: 12px; + letter-spacing: 0.01em; - &::before { - content: "("; + tr td.series-value { + font-weight: $medium; + } } - &::after { - content: ")"; + // -- standard columns -- + td { + vertical-align: baseline; } - } - } - .values { - display: flex; - align-items: baseline; - justify-content: space-between; - color: $dark-grey; - padding: 2px 0; - line-height: 21px; - font-size: 18px; - font-weight: $bold; - - .range { - display: flex; - flex-wrap: wrap; - align-items: baseline; - column-gap: 0.2em; - flex-grow: 1; - - .term { - overflow-wrap: anywhere; + td.series-color { + padding-left: 0; + .swatch { + width: 12px; + height: 12px; + display: inline-block; + margin-right: 0.3em; + text-align: left; + position: relative; + } } - svg.arrow { - height: 14px; - padding-right: 0.15em; - &.up path { - fill: $green; + td.series-name { + padding-right: 0.9em; + line-height: 16px; + width: 100%; + .parenthetical { + color: $grey; } - &.down path { - fill: $red; + + .annotation { + display: block; + color: $grey; + font-size: 12px; + letter-spacing: 0.01em; } - &.right path { - fill: $grey; + } + + td.series-value { + font-weight: $bold; + text-align: right; + white-space: nowrap; + + &.missing::before { + content: "No data"; + color: $light-grey; + } + + & + .series-value { + padding-left: 0.5em; } } - } - .time-notice { - position: relative; - font-weight: $medium; - font-size: 14px; - line-height: 21px; - padding-left: 20px; - svg.fa-circle-info { - position: absolute; - top: 3px; - left: 0; + td.time-notice { + white-space: nowrap; + font-weight: $medium; + text-indent: 20px; + text-align: right; + padding-right: 0; + color: $grey; } - color: $grey; - } - } - } - .variable + .variable { - margin-top: 4px; - padding-top: 8px; - border-top: 1px solid $light-grey; - } + // -- special row types -- - table.series-list { - color: $dark-grey; - font-size: 14px; - line-height: 22px; - font-weight: $medium; - white-space: normal; - border-collapse: collapse; - width: 100%; - - // only used if rows have ≥2 values - thead { - font-size: 12px; - letter-spacing: 0.01em; - - tr td.series-value { - font-weight: $medium; - } - } + tr.blurred { + color: $light-grey; + .series-color .swatch { + opacity: 0.25; + } + .series-name span { + color: inherit; + } + } - // -- standard columns -- - td { - vertical-align: baseline; - } + tr.spacer { + line-height: 2px; + font-size: 2px; + &::before { + content: "\00a0"; + } + } - td.series-color { - padding-left: 0; - .swatch { - width: 12px; - height: 12px; - display: inline-block; - margin-right: 0.3em; - text-align: left; - position: relative; - } - } + tr.total { + td { + line-height: 14px; + } + td:nth-child(2), + td:nth-child(3) { + border-top: 1px solid $background-stroke; + vertical-align: bottom; + } + td:last-child::before { + content: "\200a"; + height: 5px; + display: block; + } + } - td.series-name { - padding-right: 0.9em; - line-height: 16px; - width: 100%; - .parenthetical { - color: $grey; - } + tr.total--top { + td:nth-child(2), + td:nth-child(3) { + border-bottom: 1px solid $background-stroke; + vertical-align: top; + } + } - .annotation { - display: block; - color: $grey; - font-size: 12px; - letter-spacing: 0.01em; - } - } + // highlight hovered row + &.focal { + tr:not(.focused, .total) td { + opacity: 0.6; + } - td.series-value { - font-weight: $bold; - text-align: right; - white-space: nowrap; + td.series-value { + font-weight: $medium; + } - &.missing::before { - content: "No data"; - color: $light-grey; - } + tr.focused td { + font-weight: $bold; - & + .series-value { - padding-left: 0.5em; - } - } + .parenthetical { + font-weight: $medium; + } + &.time-notice { + font-weight: $medium; + } + } + } - td.time-notice { - white-space: nowrap; - font-weight: $medium; - text-indent: 20px; - text-align: right; - padding-right: 0; - color: $grey; - } + // hide unused color swatch column + &:not(.swatched) { + td.series-color { + display: none; + } + } - // -- special row types -- + // overlay a diagonal line pattern on striped swatches + tr.striped .series-color .swatch::before { + content: " "; + position: absolute; + width: 100%; + height: 100%; + @include diagonal-background(transparent, white); + } - tr.blurred { - color: $light-grey; - .series-color .swatch { - opacity: 0.25; - } - .series-name span { - color: inherit; + tr.striped.blurred .series-color .swatch::before { + @include diagonal-background($grey, white); + } } - } - tr.spacer { - line-height: 2px; - font-size: 2px; - &::before { - content: "\00a0"; + .hoverIndicator circle { + stroke-width: 1; + r: 5px; } } - tr.total { - td { - line-height: 14px; - } - td:nth-child(2), - td:nth-child(3) { - border-top: 1px solid $background-stroke; - vertical-align: bottom; - } - td:last-child::before { - content: "\200a"; - height: 5px; - display: block; + // tolerance captions w/ circle-i icon or projection warning with striped swatch + .endmatter { + position: relative; + color: $grey; + padding: 4px 12px; + border-radius: 0 0 3px 3px; + border-top: 1px solid $light-grey; + + .line { + margin: 4px 0; } - } - // highlight hovered row - &.focal { - tr:not(.focused, .total) td { - opacity: 0.6; + .line.no-icon { + font-style: italic; } - td.series-value { - font-weight: $medium; + // add a top border to the last line if it doesn't have an icon and there are multiple lines + &.multiple-lines .line.no-icon:last-of-type { + border-top: 1px solid $background-stroke; + padding-top: 5px; + margin-top: 6px; } - tr.focused td { - font-weight: $bold; + .icon { + position: absolute; + width: 12px; - .parenthetical { - font-weight: $medium; - } - &.time-notice { - font-weight: $medium; + &.stripes { + content: " "; + height: 12px; + @include diagonal-background($grey, white); } } - } - // hide unused color swatch column - &:not(.swatched) { - td.series-color { - display: none; + p { + font-size: 12px; + letter-spacing: 0.01em; + line-height: 15px; + margin: 0; + max-width: 260px; } - } - - // overlay a diagonal line pattern on striped swatches - tr.striped .series-color .swatch::before { - content: " "; - position: absolute; - width: 100%; - height: 100%; - @include diagonal-background(transparent, white); - } - tr.striped.blurred .series-color .swatch::before { - @include diagonal-background($grey, white); + .icon ~ p { + padding-left: 19px; + } } - } - .hoverIndicator circle { - stroke-width: 1; - r: 5px; - } - } + .icon-circled-s { + --size: 12px; - // tolerance captions w/ circle-i icon or projection warning with striped swatch - .endmatter { - position: relative; - color: $grey; - padding: 4px 12px; - border-radius: 0 0 3px 3px; - border-top: 1px solid $light-grey; + position: relative; + width: var(--size); + height: var(--size); + font-size: var(--size); + bottom: -1px; // small visual correction - .line { - margin: 4px 0; - } - - .line.no-icon { - font-style: italic; - } + &.as-superscript { + --size: 10px; - // add a top border to the last line if it doesn't have an icon and there are multiple lines - &.multiple-lines .line.no-icon:last-of-type { - border-top: 1px solid $background-stroke; - padding-top: 5px; - margin-top: 6px; - } + display: inline-block; + bottom: 0.7em; + margin-left: 2px; + } - .icon { - position: absolute; - width: 12px; + .circle { + position: absolute; + width: 100%; + height: 100%; + border: 1px solid $grey; + border-radius: 50%; + } - &.stripes { - content: " "; - height: 12px; - @include diagonal-background($grey, white); + svg { + font-size: 0.6em; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $grey; + } } - } - - p { - font-size: 12px; - letter-spacing: 0.01em; - line-height: 15px; - margin: 0; - max-width: 260px; - } - - .icon ~ p { - padding-left: 19px; - } - } - - .icon-circled-s { - --size: 12px; - - position: relative; - width: var(--size); - height: var(--size); - font-size: var(--size); - bottom: -1px; // small visual correction - - &.as-superscript { - --size: 10px; - - display: inline-block; - bottom: 0.7em; - margin-left: 2px; - } - - .circle { - position: absolute; - width: 100%; - height: 100%; - border: 1px solid $grey; - border-radius: 50%; - } - - svg { - font-size: 0.6em; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: $grey; - } - } - // class names passed to the `dissolve` prop control fade-out timing - &.dissolve { - opacity: 0; - transition: opacity $fade-time $fade-delay; - &.immediate { - transition: opacity $fade-time; - } - } - - // - // ‘TEMPORARY’ FIX: - // Until the map variable displayNames can be copy-edited, hide the variable name entirely - // and use just the unit (if present) as a label - // - &#mapTooltip { - .variable .definition { - .name { - display: none; + // class names passed to the `dissolve` prop control fade-out timing + &.dissolve { + opacity: 0; + transition: opacity $fade-time $fade-delay; + &.immediate { + transition: opacity $fade-time; + } } - .unit::after, - .unit::before { - content: none; - } - } - } -} + // + // ‘TEMPORARY’ FIX: + // Until the map variable displayNames can be copy-edited, hide the variable name entirely + // and use just the unit (if present) as a label + // + &#mapTooltip { + .variable .definition { + .name { + display: none; + } -// adapt to smaller display areas -@container grapher (max-width:900px) { - .Tooltip { - .content { - padding: 4px 12px; - .variable .values { - font-size: 16px; - line-height: 18px; - svg.arrow { - height: 12px; - padding-right: 2px; + .unit::after, + .unit::before { + content: none; + } } } + } - .variable + .variable { - padding-top: 4px; - } - - table.series-list { - font-size: 12px; - line-height: 18px; - letter-spacing: 0.01em; + // adapt to smaller display areas + @container grapher (max-width:900px) { + .Tooltip { + .content { + padding: 4px 12px; + .variable .values { + font-size: 16px; + line-height: 18px; + svg.arrow { + height: 12px; + padding-right: 2px; + } + } - tr.total { - td:last-child::before { - height: 4px; + .variable + .variable { + padding-top: 4px; } - } - td.series-name .annotation { - font-size: 10px; - line-height: 12px; + table.series-list { + font-size: 12px; + line-height: 18px; + letter-spacing: 0.01em; + + tr.total { + td:last-child::before { + height: 4px; + } + } + + td.series-name .annotation { + font-size: 10px; + line-height: 12px; + } + } } } } diff --git a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx index 4ad1ce0de63..226fa4fad36 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx @@ -4,12 +4,17 @@ import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faInfoCircle } from "@fortawesome/free-solid-svg-icons" -import { Bounds, PointVector } from "@ourworldindata/utils" +import { + Bounds, + PointVector, + GrapherTooltipAnchor, +} from "@ourworldindata/utils" import { TooltipProps, TooltipManager, TooltipFadeMode, TooltipFooterIcon, + TooltipContext, } from "./TooltipProps" import { IconCircledS } from "./TooltipContents.js" export * from "./TooltipContents.js" @@ -71,11 +76,13 @@ export class TooltipState { @observer class TooltipCard extends React.Component< TooltipProps & { - containerWidth: number - containerHeight: number bounds?: Bounds + containerWidth?: number + containerHeight?: number } > { + static contextType = TooltipContext + private base: React.RefObject = React.createRef() @observable.struct private bounds?: Bounds @@ -105,32 +112,44 @@ class TooltipCard extends React.Component< offsetY = 0, } = this.props - if (this.props.offsetYDirection === "upward") { - offsetY = -offsetY - (this.bounds?.height ?? 0) - } + const style = { ...this.props.style } - if ( - this.props.offsetXDirection === "left" && - this.props.x > (this.bounds?.width ?? 0) - ) { - offsetX = -offsetX - (this.bounds?.width ?? 0) - } + // if container dimensions are given, we make sure the tooltip + // is positioned within the container bounds + if (this.props.containerWidth && this.props.containerHeight) { + if (this.props.offsetYDirection === "upward") { + offsetY = -offsetY - (this.bounds?.height ?? 0) + } + + if ( + this.props.offsetXDirection === "left" && + this.props.x > (this.bounds?.width ?? 0) + ) { + offsetX = -offsetX - (this.bounds?.width ?? 0) + } + + // Ensure tooltip remains inside chart + let left = this.props.x + offsetX + let top = this.props.y + offsetY + if (this.bounds) { + if (left + this.bounds.width > this.props.containerWidth) + left -= this.bounds.width + 2 * offsetX // flip left + if ( + top + this.bounds.height * 0.75 > + this.props.containerHeight + ) + top -= this.bounds.height + 2 * offsetY // flip upwards eventually... + if (top + this.bounds.height > this.props.containerHeight) + top = this.props.containerHeight - this.bounds.height // ...but first pin at bottom + + if (left < 0) left = 0 // pin on left + if (top < 0) top = 0 // pin at top + } - // Ensure tooltip remains inside chart - let left = this.props.x + offsetX - let top = this.props.y + offsetY - if (this.bounds) { - if (left + this.bounds.width > this.props.containerWidth) - left -= this.bounds.width + 2 * offsetX // flip left - if (top + this.bounds.height * 0.75 > this.props.containerHeight) - top -= this.bounds.height + 2 * offsetY // flip upwards eventually... - if (top + this.bounds.height > this.props.containerHeight) - top = this.props.containerHeight - this.bounds.height // ...but first pin at bottom - - if (left < 0) left = 0 // pin on left - if (top < 0) top = 0 // pin at top + style.position = "absolute" + style.left = left + style.top = top } - const style = { left, top, ...this.props.style } // add a preposition to unit-based subtitles const hasHeader = title !== undefined || subtitle !== undefined @@ -149,6 +168,14 @@ class TooltipCard extends React.Component< // skip transition delay if requested const immediate = dissolve === "immediate" + // ignore the given width and max-width if the tooltip position is fixed + // since we want to use the full width of the screen in that case + const isPinnedToBottom = + this.context.anchor === GrapherTooltipAnchor.bottom + if (isPinnedToBottom && (style.width || style.maxWidth)) { + style.width = style.maxWidth = undefined + } + return (
)} - {children &&
{children}
} - {footer && footer.length > 0 && ( -
1, - })} - > - {footer?.map(({ icon, text }) => ( -
- {TOOLTIP_ICON[icon]} -

{text}

-
- ))} -
- )} +
+ {children &&
{children}
} + {footer && footer.length > 0 && ( +
1, + })} + > + {footer?.map(({ icon, text }) => ( +
+ {TOOLTIP_ICON[icon]} +

{text}

+
+ ))} +
+ )} +
) } @@ -201,24 +232,51 @@ class TooltipCard extends React.Component< @observer export class TooltipContainer extends React.Component<{ tooltipProvider: TooltipManager - containerWidth: number - containerHeight: number + anchor?: GrapherTooltipAnchor + // if container dimensions are given, the tooltip will be positioned within its bounds + containerWidth?: number + containerHeight?: number }> { + @computed private get tooltip(): TooltipProps | undefined { + const { tooltip } = this.props.tooltipProvider + return tooltip?.get() + } + + @computed private get anchor(): GrapherTooltipAnchor { + return this.props.anchor ?? GrapherTooltipAnchor.mouse + } + + componentDidMount(): void { + document.addEventListener("click", this.onDocumentClick) + } + + componentWillUnmount(): void { + document.removeEventListener("click", this.onDocumentClick) + } + + @action.bound private onDocumentClick(): void { + const { tooltip } = this + if (tooltip?.shouldDismissOnClickOutside) tooltip?.dismiss?.() + } + @computed private get rendered(): React.ReactElement | null { - const tooltipsMap = this.props.tooltipProvider.tooltips - if (!tooltipsMap) return null - const tooltips = Object.entries(tooltipsMap.toJSON()) + const { tooltip } = this + if (!tooltip) return null + const isFixedToBottom = this.anchor === GrapherTooltipAnchor.bottom return ( -
- {tooltips.map(([id, tooltip]) => ( + +
- ))} -
+
+ ) } @@ -234,11 +292,11 @@ export class Tooltip extends React.Component { } @action.bound private connectTooltipToContainer(): void { - this.props.tooltipManager.tooltips?.set(this.props.id, this.props) + this.props.tooltipManager.tooltip?.set(this.props) } @action.bound private removeToolTipFromContainer(): void { - this.props.tooltipManager.tooltips?.delete(this.props.id) + this.props.tooltipManager.tooltip?.set(undefined) } componentDidUpdate(): void { diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx index c83f3132235..7b10a798b3f 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx @@ -11,11 +11,14 @@ import { isNumber, sortBy, formatInlineList, + GrapherTooltipAnchor, + excludeUndefined, } from "@ourworldindata/utils" import { TooltipTableProps, TooltipValueProps, TooltipValueRangeProps, + TooltipContext, } from "./TooltipProps" export const NO_DATA_COLOR = "#999" @@ -167,6 +170,8 @@ class Variable extends React.Component<{ } export class TooltipTable extends React.Component { + static contextType = TooltipContext + render(): React.ReactElement | null { const { columns, totals, rows } = this.props, focal = rows.some((row) => row.focused), @@ -181,7 +186,21 @@ export class TooltipTable extends React.Component { tooTrivial = zip(columns, totals ?? []).every( ([column, total]) => !!column?.formatValueShort(total).match(/^100(\.0+)?%/) - ) + ), + showTotals = totals && !(tooEmpty || tooTrivial) + + // if the tooltip is pinned to the bottom, show the total at the top, + // so that it's always visible even if the tooltip is scrollable + const showTotalsAtTop = + this.context?.anchor === GrapherTooltipAnchor.bottom + + const totalsCells = zip(columns, totals!).map(([column, total]) => ( + + {column && total !== undefined + ? column.formatValueShort(total, format) + : null} + + )) return ( @@ -199,6 +218,16 @@ export class TooltipTable extends React.Component { )} + {showTotals && showTotalsAtTop && ( + <> + + + + {totalsCells} + + + + )} {rows.map((row) => { const { name, @@ -241,24 +270,26 @@ export class TooltipTable extends React.Component { )} - {zip(columns, values).map(([column, value]) => { - const missing = value === undefined - return column ? ( - + ) : null + } + )} {notice && ( ) })} - {totals && !(tooEmpty || tooTrivial) && ( + {showTotals && !showTotalsAtTop && ( <> - {zip(columns, totals).map(([column, total]) => ( - - ))} + {totalsCells} )} diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts index fdbd269246c..9cdf38009a2 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts @@ -1,10 +1,14 @@ -import { ObservableMap } from "mobx" +import React from "react" import { CoreColumn } from "@ourworldindata/core-table" -import { TickFormattingOptions } from "@ourworldindata/utils" +import { + GrapherTooltipAnchor, + TickFormattingOptions, +} from "@ourworldindata/utils" +import { IObservableValue } from "mobx" // We can't pass the property directly because we need it to be observable. export interface TooltipManager { - tooltips?: ObservableMap + tooltip?: IObservableValue } export type TooltipFadeMode = "delayed" | "immediate" | "none" @@ -31,6 +35,8 @@ export interface TooltipProps { dissolve?: TooltipFadeMode // flag that the tooltip should begin fading out tooltipManager: TooltipManager children?: React.ReactNode + dismiss?: () => void + shouldDismissOnClickOutside?: boolean } export interface TooltipValueProps { @@ -71,3 +77,7 @@ export interface TooltipTableData { value: number fake?: boolean } + +export const TooltipContext = React.createContext<{ + anchor?: GrapherTooltipAnchor +}>({}) diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index a875400a223..67d13534869 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -198,6 +198,13 @@ export enum ScatterPointLabelStrategy { y = "y", } +export enum GrapherTooltipAnchor { + // the tooltip is positioned relative to the mouse cursor + mouse = "mouse", + // the tooltip is pinned to the bottom of the screen + bottom = "bottom", +} + export interface AnnotationFieldsInTitle { entity?: boolean time?: boolean diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index e99b9b16d3e..2f04a2cb6b8 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -100,6 +100,7 @@ export { type DetailsMarker, GrapherWindowType, AxisMinMaxValueStr, + GrapherTooltipAnchor, } from "./grapherTypes/GrapherTypes.js" export {
Total
- {!missing && - column.formatValueShort( - value, - format + {zip(columns, excludeUndefined(values)).map( + ([column, value]) => { + const missing = value === undefined + return column ? ( + - ) : null - })} + > + {!missing && + column.formatValueShort( + value, + format + )} + {" "} @@ -268,25 +299,13 @@ export class TooltipTable extends React.Component {
Total - {column && total !== undefined - ? column.formatValueShort( - total, - format - ) - : null} -