Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ (grapher) pin tooltips to the bottom on mobile / TAS-664 #4082

Merged
merged 9 commits into from
Nov 14, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -71,8 +72,6 @@ export interface CaptionedChartManager
// layout & style
isSmall?: boolean
isMedium?: boolean
framePaddingHorizontal?: number
framePaddingVertical?: number
fontSize?: number
backgroundColor?: string

Expand Down Expand Up @@ -109,6 +108,9 @@ const CONTROLS_ROW_HEIGHT = 32

@observer
export class CaptionedChart extends React.Component<CaptionedChartProps> {
protected framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL
protected framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL

@computed protected get manager(): CaptionedChartManager {
return this.props.manager
}
Expand All @@ -124,18 +126,6 @@ export class CaptionedChart extends React.Component<CaptionedChartProps> {
)
}

@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
}
Expand Down Expand Up @@ -357,7 +347,6 @@ export class CaptionedChart extends React.Component<CaptionedChartProps> {
<TimelineComponent
timelineController={this.manager.timelineController!}
maxWidth={this.maxWidth}
framePaddingHorizontal={this.framePaddingHorizontal}
/>
)
}
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/@ourworldindata/grapher/src/chart/ChartManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,6 +92,7 @@ export interface ChartManager {
isExportingForSocialMedia?: boolean
secondaryColorInStaticCharts?: string
backgroundColor?: Color
shouldPinTooltipToBottom?: boolean

detailsOrderedByReference?: string[]
detailsMarkerInSvg?: DetailsMarker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChartComponentProps> {
Expand Down
13 changes: 13 additions & 0 deletions packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,8 +27,6 @@ export interface ControlsRowManager
sidePanelBounds?: Bounds
availableTabs?: GrapherTabOption[]
showEntitySelectionToggle?: boolean
framePaddingHorizontal?: number
framePaddingVertical?: number
}

@observer
Expand All @@ -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
Expand All @@ -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
}
Expand Down
93 changes: 64 additions & 29 deletions packages/@ourworldindata/grapher/src/core/Grapher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
sortBy,
extractDetailsFromSyntax,
omit,
isTouchDevice,
} from "@ourworldindata/utils"
import {
MarkdownTextWrap,
Expand Down Expand Up @@ -105,6 +106,7 @@ import {
GrapherWindowType,
Color,
GRAPHER_QUERY_PARAM_KEYS,
GrapherTooltipAnchor,
} from "@ourworldindata/types"
import {
BlankOwidTable,
Expand All @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -210,6 +213,7 @@ import {
type EntitySelectorState,
} from "../entitySelector/EntitySelector"
import { SlideInDrawer } from "../slideInDrawer/SlideInDrawer"
import { BodyDiv } from "../bodyDiv/BodyDiv"

declare global {
interface Window {
Expand Down Expand Up @@ -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<LegacyGrapherInterface> = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2820,33 +2834,53 @@ export class Grapher
<EntitySelector manager={this} autoFocus={true} />
</SlideInDrawer>

{/* tooltip */}
<TooltipContainer
containerWidth={this.captionedChartBounds.width}
containerHeight={this.captionedChartBounds.height}
tooltipProvider={this}
/>
{/* tooltip: either pin to the bottom or render into the chart area */}
{this.shouldPinTooltipToBottom ? (
<BodyDiv>
<TooltipContainer
tooltipProvider={this}
anchor={GrapherTooltipAnchor.bottom}
/>
</BodyDiv>
) : (
<TooltipContainer
tooltipProvider={this}
containerWidth={this.captionedChartBounds.width}
containerHeight={this.captionedChartBounds.height}
/>
)}
</>
)
}

// 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading