diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index 9aaffebffd6..502c56a156c 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -19,8 +19,9 @@ import { BASE_FONT_SIZE, Patterns, STATIC_EXPORT_DETAIL_SPACING, - DEFAULT_GRAPHER_FRAME_PADDING, GRAPHER_BACKGROUND_DEFAULT, + GRAPHER_FRAME_PADDING_VERTICAL, + GRAPHER_FRAME_PADDING_HORIZONTAL, } from "../core/GrapherConstants" import { MapChartManager } from "../mapCharts/MapChartConstants" import { ChartManager } from "../chart/ChartManager" @@ -71,8 +72,6 @@ export interface CaptionedChartManager // layout & style isSmall?: boolean isMedium?: boolean - framePaddingHorizontal?: number - framePaddingVertical?: number fontSize?: number backgroundColor?: string @@ -109,6 +108,9 @@ const CONTROLS_ROW_HEIGHT = 32 @observer export class CaptionedChart extends React.Component { + protected framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + protected framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + @computed protected get manager(): CaptionedChartManager { return this.props.manager } @@ -124,18 +126,6 @@ export class CaptionedChart extends React.Component { ) } - @computed protected get framePaddingVertical(): number { - return ( - this.manager.framePaddingVertical ?? DEFAULT_GRAPHER_FRAME_PADDING - ) - } - - @computed protected get framePaddingHorizontal(): number { - return ( - this.manager.framePaddingHorizontal ?? DEFAULT_GRAPHER_FRAME_PADDING - ) - } - @computed protected get verticalPadding(): number { return this.manager.isSmall ? 8 : this.manager.isMedium ? 12 : 16 } @@ -357,7 +347,6 @@ export class CaptionedChart extends React.Component { ) } @@ -495,14 +484,6 @@ export class StaticCaptionedChart extends CaptionedChart { }) } - @computed protected get framePaddingVertical(): number { - return DEFAULT_GRAPHER_FRAME_PADDING - } - - @computed protected get framePaddingHorizontal(): number { - return DEFAULT_GRAPHER_FRAME_PADDING - } - @computed private get paddedBounds(): Bounds { return this.bounds .padWidth(this.framePaddingHorizontal) diff --git a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts index 4f04a69d072..e4116cf0410 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartManager.ts +++ b/packages/@ourworldindata/grapher/src/chart/ChartManager.ts @@ -34,7 +34,7 @@ export interface ChartManager { isRelativeMode?: boolean comparisonLines?: ComparisonLineConfig[] showLegend?: boolean - tooltips?: TooltipManager["tooltips"] + tooltip?: TooltipManager["tooltip"] baseColorScheme?: ColorSchemeName invertColorScheme?: boolean compareEndPointsOnly?: boolean @@ -92,6 +92,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..5f8fa7bd974 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartTypeMap.tsx @@ -16,7 +16,7 @@ import { MarimekkoChart } from "../stackedCharts/MarimekkoChart" interface ChartComponentProps { manager: ChartManager bounds?: Bounds - containerElement?: any // todo: remove? + containerElement?: HTMLDivElement } interface ChartComponentClass extends ComponentClass { diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 7a17f9980dd..c6285eabed2 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -107,3 +107,16 @@ export function getShortNameForEntity(entityName: string): string | undefined { const country = getCountryByName(entityName) return country?.shortName } + +export function isTargetOutsideElement( + target: EventTarget, + element: Node +): boolean { + const targetNode = target as Node + return ( + !element.contains(targetNode) && + // check that the target is still mounted to the document (we also get + // click events on nodes that have since been removed by React) + document.contains(targetNode) + ) +} diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx index 53cfcae4414..daedbd24493 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx @@ -8,7 +8,10 @@ import { EntityName, ChartTypeName, FacetStrategy } from "@ourworldindata/types" import { DEFAULT_BOUNDS } from "@ourworldindata/utils" import { SelectionArray } from "../selection/SelectionArray" import { ChartDimension } from "../chart/ChartDimension" -import { makeSelectionArray } from "../chart/ChartUtils.js" +import { + isTargetOutsideElement, + makeSelectionArray, +} from "../chart/ChartUtils.js" import { AxisConfig } from "../axis/AxisConfig" import { AxisScaleToggle } from "./settings/AxisScaleToggle" @@ -245,8 +248,7 @@ export class SettingsMenu extends React.Component<{ if ( this.active && this.contentRef?.current && - !this.contentRef.current.contains(e.target as Node) && - document.contains(e.target as Node) + isTargetOutsideElement(e.target!, this.contentRef.current) ) this.toggleVisibility() } diff --git a/packages/@ourworldindata/grapher/src/controls/controlsRow/ControlsRow.tsx b/packages/@ourworldindata/grapher/src/controls/controlsRow/ControlsRow.tsx index 0ae2db8b681..a2c5f9d38c2 100644 --- a/packages/@ourworldindata/grapher/src/controls/controlsRow/ControlsRow.tsx +++ b/packages/@ourworldindata/grapher/src/controls/controlsRow/ControlsRow.tsx @@ -14,7 +14,10 @@ import { MapProjectionMenuManager, } from "../MapProjectionMenu" import { SettingsMenu, SettingsMenuManager } from "../SettingsMenu" -import { DEFAULT_GRAPHER_FRAME_PADDING } from "../../core/GrapherConstants" +import { + GRAPHER_FRAME_PADDING_HORIZONTAL, + GRAPHER_FRAME_PADDING_VERTICAL, +} from "../../core/GrapherConstants" export interface ControlsRowManager extends ContentSwitchersManager, @@ -24,8 +27,6 @@ export interface ControlsRowManager sidePanelBounds?: Bounds availableTabs?: GrapherTabOption[] showEntitySelectionToggle?: boolean - framePaddingHorizontal?: number - framePaddingVertical?: number } @observer @@ -34,6 +35,9 @@ export class ControlsRow extends React.Component<{ maxWidth?: number settingsMenuTop?: number }> { + private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + static shouldShow(manager: ControlsRowManager): boolean { const test = new ControlsRow({ manager }) return test.showControlsRow @@ -47,18 +51,6 @@ export class ControlsRow extends React.Component<{ return this.props.maxWidth ?? DEFAULT_BOUNDS.width } - @computed private get framePaddingHorizontal(): number { - return ( - this.manager.framePaddingHorizontal ?? DEFAULT_GRAPHER_FRAME_PADDING - ) - } - - @computed private get framePaddingVertical(): number { - return ( - this.manager.framePaddingVertical ?? DEFAULT_GRAPHER_FRAME_PADDING - ) - } - @computed private get sidePanelWidth(): number { return this.manager.sidePanelBounds?.width ?? 0 } diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index cf38c20e544..77b446373b9 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, Color, GRAPHER_QUERY_PARAM_KEYS, + GrapherTooltipAnchor, } from "@ourworldindata/types" import { BlankOwidTable, @@ -118,7 +120,6 @@ import { ThereWasAProblemLoadingThisChart, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT, - DEFAULT_GRAPHER_FRAME_PADDING, DEFAULT_GRAPHER_ENTITY_TYPE, DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL, GRAPHER_DARK_TEXT, @@ -129,6 +130,8 @@ import { isPopulationVariableETLPath, GRAPHER_BACKGROUND_BEIGE, GRAPHER_BACKGROUND_DEFAULT, + GRAPHER_FRAME_PADDING_HORIZONTAL, + GRAPHER_FRAME_PADDING_VERTICAL, } from "../core/GrapherConstants" import { defaultGrapherConfig } from "../schema/defaultGrapherConfig" import { loadVariableDataAndMetadata } from "./loadVariable" @@ -210,6 +213,7 @@ import { type EntitySelectorState, } from "../entitySelector/EntitySelector" import { SlideInDrawer } from "../slideInDrawer/SlideInDrawer" +import { BodyDiv } from "../bodyDiv/BodyDiv" declare global { interface Window { @@ -462,6 +466,10 @@ export class Grapher this.props.dataApiUrl ?? "https://api.ourworldindata.org/v1/indicators/" @observable.ref externalQueryParams: QueryParams + + private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + @observable.ref inputTable: OwidTable @observable.ref legacyConfigAsAuthored: Partial = {} @@ -862,7 +870,9 @@ export class Grapher @observable.ref isExportingToSvgOrPng = false @observable.ref isSocialMediaExport = false - tooltips?: TooltipManager["tooltips"] = observable.map({}, { deep: false }) + tooltip?: TooltipManager["tooltip"] = observable.box(undefined, { + deep: false, + }) @observable.ref isPlaying = false @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished @@ -2184,6 +2194,10 @@ export class Grapher return isMobile() } + @computed get isTouchDevice(): boolean { + return isTouchDevice() + } + @computed private get externalBounds(): Bounds { return this.props.bounds ?? DEFAULT_BOUNDS } @@ -2820,12 +2834,21 @@ export class Grapher - {/* tooltip */} - + {/* tooltip: either pin to the bottom or render into the chart area */} + {this.shouldPinTooltipToBottom ? ( + + + + ) : ( + + )} ) } @@ -2833,20 +2856,31 @@ 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 - - if (this.slug && !this.hasLoggedGAViewEvent) { - this.analytics.logGrapherView(this.slug) - this.hasLoggedGAViewEvent = true + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.hasBeenVisible = true + + if (this.slug && !this.hasLoggedGAViewEvent) { + this.analytics.logGrapherView(this.slug) + this.hasLoggedGAViewEvent = true + } } - observer.disconnect() - } - }) - }) + // 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 { @@ -2897,17 +2931,9 @@ export class Grapher return this.props.baseFontSize ?? this.baseFontSize } - @computed get framePaddingHorizontal(): number { - return DEFAULT_GRAPHER_FRAME_PADDING - } - - @computed get framePaddingVertical(): number { - return DEFAULT_GRAPHER_FRAME_PADDING - } - @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 @@ -2960,6 +2986,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 { @@ -3294,6 +3324,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 diff --git a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts index 3fabd87cb2a..86042194b1b 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts @@ -17,7 +17,9 @@ export const GRAPHER_LOADED_EVENT_NAME = "grapherLoaded" export const DEFAULT_GRAPHER_WIDTH = 850 export const DEFAULT_GRAPHER_HEIGHT = 600 -export const DEFAULT_GRAPHER_FRAME_PADDING = 16 +export const GRAPHER_FRAME_PADDING_VERTICAL = 16 +export const GRAPHER_FRAME_PADDING_HORIZONTAL = 16 + export const STATIC_EXPORT_DETAIL_SPACING = 8 export const GRAPHER_BACKGROUND_DEFAULT = "#ffffff" diff --git a/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx b/packages/@ourworldindata/grapher/src/facetChart/FacetChart.tsx index 956ee9d711a..8fd30e2bed9 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/footer/Footer.tsx b/packages/@ourworldindata/grapher/src/footer/Footer.tsx index f731f5978bf..275486dc7e5 100644 --- a/packages/@ourworldindata/grapher/src/footer/Footer.tsx +++ b/packages/@ourworldindata/grapher/src/footer/Footer.tsx @@ -18,7 +18,7 @@ import { FooterManager } from "./FooterManager" import { ActionButtons } from "../controls/ActionButtons" import { BASE_FONT_SIZE, - DEFAULT_GRAPHER_FRAME_PADDING, + GRAPHER_FRAME_PADDING_HORIZONTAL, GRAPHER_DARK_TEXT, } from "../core/GrapherConstants" @@ -82,12 +82,6 @@ export class Footer< return this.props.maxWidth ?? DEFAULT_BOUNDS.width } - @computed private get framePaddingHorizontal(): number { - return ( - this.manager.framePaddingHorizontal ?? DEFAULT_GRAPHER_FRAME_PADDING - ) - } - @computed protected get useBaseFontSize(): boolean { return !!this.manager.useBaseFontSize } @@ -592,7 +586,7 @@ export class Footer<