diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index cb7f1f589b3..b5e477b3c75 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -25,11 +25,7 @@ import { MapChartManager } from "../mapCharts/MapChartConstants" import { ChartManager } from "../chart/ChartManager" import { LoadingIndicator } from "../loadingIndicator/LoadingIndicator" import { FacetChart } from "../facetChart/FacetChart" -import { - faEarthAmericas, - faExternalLinkAlt, - faMap, -} from "@fortawesome/free-solid-svg-icons" +import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { FooterManager } from "../footer/FooterManager" import { HeaderManager } from "../header/HeaderManager" @@ -51,7 +47,6 @@ import { ControlsRow, ControlsRowManager, } from "../controls/controlsRow/ControlsRow" -import { LabeledSwitch } from "../controls/LabeledSwitch.js" export interface CaptionedChartManager extends ChartManager, @@ -337,15 +332,6 @@ export class CaptionedChart extends React.Component { height: chartHeight, } - const globeSwitcher: React.CSSProperties = { - height: "40px", - width: "fit-content", - margin: "10px", - display: "flex", - position: "absolute", - bottom: "0", - } - return (
"menu", + placeholder: () => "placeholder", }} {...props} /> diff --git a/packages/@ourworldindata/grapher/src/controls/MapProjectionMenu.tsx b/packages/@ourworldindata/grapher/src/controls/MapProjectionMenu.tsx index 579d74dcf8f..4647dff88a2 100644 --- a/packages/@ourworldindata/grapher/src/controls/MapProjectionMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/MapProjectionMenu.tsx @@ -6,6 +6,7 @@ import { MapProjectionName } from "@ourworldindata/types" import { MapProjectionLabels } from "../mapCharts/MapProjections" import { Dropdown } from "./Dropdown" import { DEFAULT_BOUNDS } from "@ourworldindata/utils" +import { GlobeController } from "../mapCharts/GlobeController" export { AbsRelToggle } from "./settings/AbsRelToggle" export { FacetStrategySelector } from "./settings/FacetStrategySelector" @@ -18,6 +19,8 @@ export interface MapProjectionMenuManager { mapConfig?: MapConfig isOnMapTab?: boolean hideMapProjectionMenu?: boolean + globeController?: GlobeController + isGlobe?: boolean } interface MapProjectionMenuItem { @@ -30,54 +33,61 @@ export class MapProjectionMenu extends React.Component<{ manager: MapProjectionMenuManager maxWidth?: number }> { - static shouldShow(manager: MapProjectionMenuManager): boolean { - const menu = new MapProjectionMenu({ manager }) - return menu.showMenu - } - - @computed get showMenu(): boolean { - const { hideMapProjectionMenu, isOnMapTab, mapConfig } = - this.props.manager, - { projection } = mapConfig ?? {} - return !hideMapProjectionMenu && !!(isOnMapTab && projection) - } - @computed private get maxWidth(): number { return this.props.maxWidth ?? DEFAULT_BOUNDS.width } @action.bound onChange(selected: unknown): void { const { mapConfig } = this.props.manager - if (selected && mapConfig) - mapConfig.projection = (selected as MapProjectionMenuItem).value + if (selected && mapConfig) { + const projection = (selected as MapProjectionMenuItem).value + mapConfig.projection = projection + + void this.props.manager.globeController?.rotateToProjection( + projection + ) + } } @computed get options(): MapProjectionMenuItem[] { - return Object.values(MapProjectionName).map((projectName) => { - return { - value: projectName, - label: MapProjectionLabels[projectName], - } - }) + return Object.values(MapProjectionName) + .filter((projectionName) => + this.props.manager.isGlobe + ? projectionName !== MapProjectionName.World + : true + ) + .map((projectionName) => { + return { + value: projectionName, + label: MapProjectionLabels[projectionName], + } + }) } @computed get value(): MapProjectionMenuItem | null { const { projection } = this.props.manager.mapConfig ?? {} - return this.options.find((opt) => projection === opt.value) ?? null + const option = + this.options.find((opt) => projection === opt.value) ?? null + if (this.props.manager.isGlobe) return option + const world = + this.options.find((opt) => opt.value === MapProjectionName.World) ?? + null + return option ?? world } render(): React.ReactElement | null { - return this.showMenu ? ( + return (
- ) : null + ) } } diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx index 667af330e03..e91bb7eb92a 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx @@ -30,6 +30,12 @@ import { TableFilterToggle, TableFilterToggleManager, } from "./settings/TableFilterToggle" +import { GlobeToggle, GlobeToggleManager } from "./settings/GlobeToggle" +import { + MapProjectionMenu, + MapProjectionMenuManager, +} from "./MapProjectionMenu" + import { OverlayHeader } from "../core/OverlayHeader" import { DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL } from "../core/GrapherConstants" @@ -48,7 +54,9 @@ export interface SettingsMenuManager FacetYDomainToggleManager, ZoomToggleManager, TableFilterToggleManager, - FacetStrategySelectionManager { + FacetStrategySelectionManager, + GlobeToggleManager, + MapProjectionMenuManager { // ArchieML directives hideFacetControl?: boolean hideRelativeToggle?: boolean @@ -59,6 +67,8 @@ export interface SettingsMenuManager hideXScaleToggle?: boolean hideYScaleToggle?: boolean hideTableFilterToggle?: boolean + hideMapProjectionMenu?: boolean + hideGlobeToggle?: boolean // chart state type: ChartTypeName @@ -95,7 +105,7 @@ export class SettingsMenu extends React.Component<{ static shouldShow(manager: SettingsMenuManager): boolean { const test = new SettingsMenu({ manager, top: 0, bottom: 0, right: 0 }) - return test.showSettingsMenuToggle + return test.showSettings } @computed get maxWidth(): number { @@ -202,9 +212,19 @@ export class SettingsMenu extends React.Component<{ ) } - @computed get showSettingsMenuToggle(): boolean { - if (this.manager.isOnMapTab) return false + @computed get showGlobeToggle(): boolean { + return !this.manager.hideGlobeToggle + } + + @computed get showMapProjectionMenu(): boolean { + return !this.manager.hideMapProjectionMenu + } + + @computed get showSettings(): boolean { if (this.manager.isOnTableTab) return this.showTableFilterToggle + if (this.manager.isOnMapTab) { + return this.showGlobeToggle || this.showMapProjectionMenu + } return !!( this.showYScaleToggle || @@ -265,11 +285,6 @@ export class SettingsMenu extends React.Component<{ return makeSelectionArray(this.manager.selection) } - @computed get shouldRenderTableControlsIntoPopup(): boolean { - const tableFilterToggleWidth = TableFilterToggle.width(this.manager) - return tableFilterToggleWidth > this.maxWidth - } - @computed get layout(): { maxHeight: string maxWidth: string @@ -282,6 +297,11 @@ export class SettingsMenu extends React.Component<{ return { maxHeight, maxWidth, top, right } } + @computed private get shouldRenderTableToggleIntoPopup(): boolean { + const toggleWidth = TableFilterToggle.width(this.manager) + return toggleWidth > this.maxWidth + } + @computed get menuContentsChart(): React.ReactElement { const { manager, @@ -378,17 +398,23 @@ export class SettingsMenu extends React.Component<{ ) } + @computed get menuContentsMap(): JSX.Element { + return ( + + + + ) + } + @computed get menu(): JSX.Element | void { - if (this.active) { - return this.menuContents - } + if (this.active) return this.menuContents } @computed get menuContents(): JSX.Element { const { manager, chartType } = this - const { isOnTableTab } = manager + const { isOnTableTab, isOnMapTab } = manager - const menuTitle = `${isOnTableTab ? "Table" : chartType} settings` + const menuTitle = `${isOnTableTab ? "Table" : isOnMapTab ? "Map" : chartType} settings` return (
@@ -410,15 +436,31 @@ export class SettingsMenu extends React.Component<{
{isOnTableTab ? this.menuContentsTable - : this.menuContentsChart} + : isOnMapTab + ? this.menuContentsMap + : this.menuContentsChart}
) } - renderSettingsButtonAndPopup(): JSX.Element { - const { active } = this + renderSettingsButtonAndPopup(): React.ReactElement | null { + const { + manager: { isOnTableTab, isOnMapTab }, + active, + showSettings, + showGlobeToggle, + shouldRenderTableToggleIntoPopup, + } = this + + if ( + !showSettings || + (isOnTableTab && !shouldRenderTableToggleIntoPopup) || + (isOnMapTab && !showGlobeToggle) + ) + return null + return (
) diff --git a/packages/@ourworldindata/grapher/src/controls/settings/GlobeToggle.tsx b/packages/@ourworldindata/grapher/src/controls/settings/GlobeToggle.tsx new file mode 100644 index 00000000000..32c8fb14545 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/settings/GlobeToggle.tsx @@ -0,0 +1,30 @@ +import React from "react" +import { action } from "mobx" +import { observer } from "mobx-react" +import { LabeledSwitch } from "../LabeledSwitch" + +export interface GlobeToggleManager { + isGlobe?: boolean +} + +@observer +export class GlobeToggle extends React.Component<{ + manager: GlobeToggleManager +}> { + private label = "Show globe" + + @action.bound onToggle(): void { + this.props.manager.isGlobe = !this.props.manager.isGlobe + } + + render(): React.ReactElement { + return ( + + ) + } +} diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 64d5146fd79..b8db35395f8 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -161,7 +161,10 @@ import { observer } from "mobx-react" import "d3-transition" import { SourcesModal, SourcesModalManager } from "../modal/SourcesModal" import { DataTableManager } from "../dataTable/DataTable" -import { MapChartManager } from "../mapCharts/MapChartConstants" +import { + DEFAULT_VIEWPORT, + MapChartManager, +} from "../mapCharts/MapChartConstants" import { MapChart } from "../mapCharts/MapChart" import { DiscreteBarChartManager } from "../barCharts/DiscreteBarChartConstants" import { Command, CommandPalette } from "../controls/CommandPalette" @@ -208,6 +211,7 @@ import { } from "../entitySelector/EntitySelector" import { SlideInDrawer } from "../slideInDrawer/SlideInDrawer" import { BodyDiv } from "../bodyDiv/BodyDiv" +import { GlobeController } from "../mapCharts/GlobeController" declare global { interface Window { @@ -297,6 +301,7 @@ export interface GrapherProgrammaticInterface extends GrapherInterface { hideYScaleToggle?: boolean hideMapProjectionMenu?: boolean hideTableFilterToggle?: boolean + hideGlobeToggle?: boolean forceHideAnnotationFieldsInTitle?: AnnotationFieldsInTitle hasTableTab?: boolean hideShareButton?: boolean @@ -652,6 +657,10 @@ export class Grapher params.showSelectionOnlyInTable === "1" ? true : undefined } + if (params.globe) { + this.isGlobe = params.globe === "1" + } + if (params.showNoDataArea) { this.showNoDataArea = params.showNoDataArea === "1" } @@ -2998,6 +3007,7 @@ export class Grapher this.showSelectionOnlyInDataTable = authorsVersion.showSelectionOnlyInDataTable this.showNoDataArea = authorsVersion.showNoDataArea + this.isGlobe = authorsVersion.isGlobe this.clearSelection() } @@ -3039,7 +3049,7 @@ export class Grapher debounceMode = false - @computed.struct get allParams(): GrapherQueryParams { + @computed.struct get allQueryParams(): GrapherQueryParams { const params: GrapherQueryParams = {} params.tab = this.tab params.xScale = this.xAxis.scaleType @@ -3056,6 +3066,7 @@ export class Grapher ? "1" : "0" params.showNoDataArea = this.showNoDataArea ? "1" : "0" + params.globe = this.isGlobe ? "1" : "0" return setSelectedEntityNamesParam( Url.fromQueryParams(params), this.selectedEntitiesIfDifferentThanAuthors @@ -3089,7 +3100,10 @@ export class Grapher // Autocomputed url params to reflect difference between current grapher state // and original config state @computed.struct get changedParams(): Partial { - return differenceObj(this.allParams, this.authorsVersion.allParams) + return differenceObj( + this.allQueryParams, + this.authorsVersion.allQueryParams + ) } // If you want to compare current state against the published grapher. @@ -3141,7 +3155,7 @@ export class Grapher // See https://github.com/owid/owid-grapher/issues/2805 let urlObj = Url.fromURL(url) if (!urlObj.queryParams.tab) { - urlObj = urlObj.updateQueryParams({ tab: this.allParams.tab }) + urlObj = urlObj.updateQueryParams({ tab: this.allQueryParams.tab }) } return urlObj.fullUrl } @@ -3190,6 +3204,9 @@ export class Grapher timelineController = new TimelineController(this) + @observable globeRotation = DEFAULT_VIEWPORT.rotation + globeController = new GlobeController(this) + // todo: restore this behavior?? onStartPlayOrDrag(): void { this.debounceMode = true @@ -3342,6 +3359,7 @@ export class Grapher @observable hideYScaleToggle = false @observable hideMapProjectionMenu = false @observable hideTableFilterToggle = false + @observable hideGlobeToggle = false // enforces hiding an annotation, even if that means that a crucial piece of information is missing from the chart title @observable forceHideAnnotationFieldsInTitle: AnnotationFieldsInTitle = { entity: false, diff --git a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts index 4a30ab55acc..e28bf6b22d8 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherConstants.ts @@ -7,6 +7,7 @@ export const GRAPHER_PAGE_BODY_CLASS = "StandaloneGrapherOrExplorerPage" export const GRAPHER_IS_IN_IFRAME_CLASS = "IsInIframe" export const GRAPHER_TIMELINE_CLASS = "timeline-component" export const GRAPHER_SIDE_PANEL_CLASS = "side-panel" +export const GRAPHER_LEGEND_CLASS = "grapher-legend" export const DEFAULT_GRAPHER_CONFIG_SCHEMA = "https://files.ourworldindata.org/schemas/grapher-schema.004.json" @@ -87,6 +88,7 @@ export const grapherInterfaceWithHiddenControlsOnly: GrapherProgrammaticInterfac hideYScaleToggle: true, hideMapProjectionMenu: true, hideTableFilterToggle: true, + hideGlobeToggle: true, map: { hideTimeline: true, }, diff --git a/packages/@ourworldindata/grapher/src/core/GrapherUtils.ts b/packages/@ourworldindata/grapher/src/core/GrapherUtils.ts new file mode 100644 index 00000000000..32530ac8de9 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/core/GrapherUtils.ts @@ -0,0 +1,20 @@ +import { + GRAPHER_LEGEND_CLASS, + GRAPHER_SIDE_PANEL_CLASS, + GRAPHER_TIMELINE_CLASS, +} from "./GrapherConstants" + +export function isElementInteractive(element: Element): boolean { + const interactiveElements = [ + "a", + "button", + "input", + `.${GRAPHER_TIMELINE_CLASS}`, + `.${GRAPHER_SIDE_PANEL_CLASS}`, + `.${GRAPHER_LEGEND_CLASS}`, + ] + const selector = interactiveElements.join(", ") + + // check if the target is an interactive element or contained within one + return element.closest(selector) !== null +} diff --git a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx index 84cf281771d..2183fa4f8da 100644 --- a/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx +++ b/packages/@ourworldindata/grapher/src/horizontalColorLegend/HorizontalColorLegends.tsx @@ -1,6 +1,7 @@ import React from "react" import { action, computed } from "mobx" import { observer } from "mobx-react" +import cx from "classnames" import { getRelativeMouse, sortBy, @@ -28,6 +29,7 @@ import { GRAPHER_FONT_SCALE_12, GRAPHER_FONT_SCALE_12_8, GRAPHER_FONT_SCALE_14, + GRAPHER_LEGEND_CLASS, } from "../core/GrapherConstants" import { darkenColorForLine } from "../color/ColorUtils" @@ -560,7 +562,7 @@ export class HorizontalNumericColorLegend extends HorizontalColorLegend { {numericLabels.map((label, index) => ( + manager.onLegendClick + ? manager.onLegendClick( + positionedBin.bin + ) + : undefined + } + style={{ + cursor: manager.onLegendClick + ? "pointer" + : "default", + }} /> ) }), @@ -795,7 +809,7 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { return ( {marks.map((mark, index) => { const isActive = activeColors?.includes(mark.bin.color) @@ -827,7 +841,11 @@ export class HorizontalCategoricalColorLegend extends HorizontalColorLegend { ? manager.onLegendClick(mark.bin) : undefined } - style={{ cursor: "default" }} + style={{ + cursor: manager.onLegendClick + ? "pointer" + : "default", + }} > {/* for hover interaction */} { + if (this.manager.isGlobe) { + void this.animateRotateTo(coords) + } else { + this.manager.globeRotation = coords + } + } + + async rotateToProjection(projectionName: MapProjectionName): Promise { + const targetCoords = VIEWPORTS[projectionName].rotation + if (this.manager.isGlobe) { + void this.animateRotateTo(targetCoords) + } else { + this.manager.globeRotation = targetCoords + } + } + + private async animateRotateTo( + targetCoords: [number, number] + ): Promise { + const currentCoords = this.manager.globeRotation + const animatedCoords = geoInterpolate(currentCoords, targetCoords) + for (let t = this.tickStep; t <= 1; t += this.tickStep) { + this.manager.globeRotation = animatedCoords(easeCubicOut(t)) + await delay(this.msPerTick) + } + } +} diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.sample.ts b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.sample.ts index 42606b91dba..a118a1d6969 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.sample.ts +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.sample.ts @@ -25,17 +25,9 @@ export const legacyMapGrapher: GrapherProgrammaticInterface = { 3512, { data: { - years: [ - ...Array(300) - .fill(1) - .map((x, i) => 2000), - ], - entities: Array(300) - .fill(1) - .map((x, i) => i), - values: Array(300) - .fill(1) - .map((x, i) => Math.random() * 30), + years: [2000, 2010, 2010], + entities: [207, 15, 207], + values: [4, 20, 34], }, metadata: { id: 3512, @@ -43,96 +35,8 @@ export const legacyMapGrapher: GrapherProgrammaticInterface = { dimensions: { entities: { values: [ - { name: "Afghanistan", id: 1, code: "AFG" }, + { name: "Afghanistan", id: 15, code: "AFG" }, { name: "Iceland", id: 207, code: "ISL" }, - { name: "Albania", id: 2, code: "ALB" }, - { name: "Algeria", id: 3, code: "DZA" }, - { name: "Andorra", id: 4, code: "AND" }, - { name: "Angola", id: 5, code: "AGO" }, - { - name: "Antigua and Barbuda", - id: 6, - code: "ATG", - }, - { name: "Brunei", id: 38, code: "BRN" }, - { name: "Bulgaria", id: 39, code: "BGR" }, - { name: "Burkina Faso", id: 40, code: "BFA" }, - { name: "Burundi", id: 41, code: "BDI" }, - { name: "Cabo Verde", id: 42, code: "CPV" }, - { name: "Cambodia", id: 43, code: "KHM" }, - { name: "Cameroon", id: 44, code: "CMR" }, - { name: "Canada", id: 45, code: "CAN" }, - { name: "Cayman Islands", id: 46, code: "CYM" }, - { - name: "Central African Republic", - id: 47, - code: "CAF", - }, - { name: "Chad", id: 48, code: "TCD" }, - { name: "Chile", id: 49, code: "CHL" }, - { name: "China", id: 50, code: "CHN" }, - { name: "Colombia", id: 51, code: "COL" }, - { name: "Comoros", id: 52, code: "COM" }, - { name: "Congo", id: 53, code: "COG" }, - { name: "Costa Rica", id: 54, code: "CRI" }, - { name: "Côte d'Ivoire", id: 55, code: "CIV" }, - { name: "Croatia", id: 56, code: "HRV" }, - { name: "Cuba", id: 57, code: "CUB" }, - { name: "Curaçao", id: 58, code: "CUW" }, - { name: "Cyprus", id: 59, code: "CYP" }, - { name: "Czechia", id: 60, code: "CZE" }, - { name: "Zimbabwe", id: 243, code: "ZWE" }, - { name: "Åland Islands", id: 244, code: "ALA" }, - { - name: "Saint Barthélemy", - id: 245, - code: "BLM", - }, - { name: "Armenia", id: 18, code: "ARM" }, - { name: "Aruba", id: 19, code: "ABW" }, - { name: "Australia", id: 20, code: "AUS" }, - { name: "Austria", id: 21, code: "AUT" }, - { name: "Azerbaijan", id: 22, code: "AZE" }, - { name: "Bahamas", id: 23, code: "BHS" }, - { name: "Bahrain", id: 24, code: "BHR" }, - { name: "Bangladesh", id: 25, code: "BGD" }, - { name: "Barbados", id: 26, code: "BRB" }, - { name: "Belarus", id: 27, code: "BLR" }, - { name: "Belgium", id: 28, code: "BEL" }, - { name: "Belize", id: 29, code: "BLZ" }, - { name: "Benin", id: 30, code: "BEN" }, - { name: "Bermuda", id: 31, code: "BMU" }, - { name: "Bhutan", id: 32, code: "BTN" }, - { name: "Bolivia", id: 33, code: "BOL" }, - { name: "Bonaire", id: 34, code: "BES" }, - { - name: "Bosnia and Herzegovina", - id: 35, - code: "BIH", - }, - { name: "Botswana", id: 36, code: "BWA" }, - { name: "Brazil", id: 37, code: "BRA" }, - { name: "Yemen", id: 179, code: "YEM" }, - { name: "Zambia", id: 180, code: "ZMB" }, - { name: "Aland Islands", id: 246, code: "ALA" }, - { name: "Saint Helena", id: 247, code: "SHN" }, - { name: "Seychelles", id: 248, code: "SYC" }, - { - name: "Solomon Islands", - id: 249, - code: "SLB", - }, - { name: "Turkmenistan", id: 250, code: "TKM" }, - { name: "Russia", id: 61, code: "RUS" }, - { name: "Canada", id: 45, code: "CAN" }, - { name: "United States", id: 193, code: "USA" }, - { name: "China", id: 50, code: "CHN" }, - { name: "Brazil", id: 37, code: "BRA" }, - { name: "Australia", id: 20, code: "AUS" }, - { name: "India", id: 62, code: "IND" }, - { name: "Argentina", id: 63, code: "ARG" }, - { name: "Kazakhstan", id: 64, code: "KAZ" }, - { name: "Algeria", id: 3, code: "DZA" }, ], }, years: { diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index da64dd198be..ff31d61e283 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -1,10 +1,10 @@ import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { ColorSchemeName, + EntityName, GrapherTabOption, MapProjectionName, SeriesName, - EntityName, } from "@ourworldindata/types" import { Bounds, @@ -14,16 +14,29 @@ import { PointVector, PrimitiveType, anyToString, + clamp, difference, exposeInstanceOnWindow, flatten, getRelativeMouse, + getUserCountryInformation, guid, isNumber, isPresent, sortBy, } from "@ourworldindata/utils" -import { Quadtree, geoOrthographic, geoPath } from "d3" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" +import { faLocationArrow } from "@fortawesome/free-solid-svg-icons" +import { isElementInteractive } from "../core/GrapherUtils" +import { + Quadtree, + geoOrthographic, + geoPath, + geoCentroid, + GeoPath, + GeoPermissibleObjects, + geoGraticule, +} from "d3" import { easeCubic } from "d3-ease" import { quadtree } from "d3-quadtree" import { select } from "d3-selection" @@ -44,7 +57,11 @@ import { NumericBin, } from "../color/ColorScaleBin" import { ColorScaleConfig } from "../color/ColorScaleConfig" -import { BASE_FONT_SIZE, Patterns } from "../core/GrapherConstants" +import { + BASE_FONT_SIZE, + GRAPHER_FONT_SCALE_9_6, + Patterns, +} from "../core/GrapherConstants" import { HorizontalCategoricalColorLegend, HorizontalColorLegendManager, @@ -58,12 +75,16 @@ import { GeoPathRoundingContext } from "./GeoPathRoundingContext" import { ChoroplethMapManager, ChoroplethSeries, + DEFAULT_ROTATIONS, + DEFAULT_VIEWPORT, GeoFeature, MAP_HOVER_TARGET_RANGE, MapBracket, MapChartManager, MapEntity, RenderFeature, + VIEWPORTS, + Viewport, } from "./MapChartConstants" import { MapConfig } from "./MapConfig" import { MapProjectionGeos } from "./MapProjections" @@ -73,6 +94,8 @@ import { WorldRegionName, WorldRegionToProjection, } from "./WorldRegionsToProjection" +import { GlobeController } from "./GlobeController" + const DEFAULT_STROKE_COLOR = "#333" const CHOROPLETH_MAP_CLASSNAME = "ChoroplethMap" @@ -140,6 +163,19 @@ const geoBoundsFor = (projectionName: MapProjectionName): Bounds[] => { return geoBoundsCache.get(projectionName)! } +let centroidCache: PointVector[] = [] +function centroids(): PointVector[] { + if (centroidCache.length > 0) return centroidCache + + const centroids = GeoFeatures.map((geo) => { + const centroid = geoCentroid(geo) + return new PointVector(centroid[0], centroid[1]) + }) + + centroidCache = centroids + return centroids +} + // Bundle GeoFeatures with the calculated info needed to render them const renderFeaturesCache = new Map() const renderFeaturesFor = ( @@ -149,9 +185,11 @@ const renderFeaturesFor = ( return renderFeaturesCache.get(projectionName)! const geoBounds = geoBoundsFor(projectionName) const geoPaths = geoPathsFor(projectionName) + const unprojectedCentroids = centroids() const feats = GeoFeatures.map((geo, index) => ({ id: geo.id as string, geo: geo, + geoCentroid: unprojectedCentroids[index], path: geoPaths[index], bounds: geoBounds[index], center: geoBounds[index].centerPos, @@ -173,6 +211,8 @@ export class MapChart clickable: boolean }>() + private persistFocusBracket = false + transformTable(table: OwidTable): OwidTable { if (!table.has(this.mapColumnSlug)) return table const transformedTable = this.dropNonMapEntities(table) @@ -285,11 +325,8 @@ export class MapChart return makeSelectionArray(this.manager.selection) } - @action.bound onClick( - d: GeoFeature, - ev: React.MouseEvent - ): void { - const entityName = d.id as any + @action.bound onClickFeature(d: GeoFeature, ev: SVGMouseEvent): void { + const entityName = d.id as EntityName if (!this.isEntityClickable(entityName)) return if (!ev.shiftKey) { @@ -307,10 +344,25 @@ export class MapChart componentWillUnmount(): void { this.onMapMouseLeave() this.onLegendMouseLeave() + + if (this.manager.base?.current) { + this.manager.base.current.removeEventListener( + "mousedown", + this.onGrapherClick + ) + } } @computed get isGlobe(): boolean { - return this.manager.isGlobe ?? false + return !!this.manager.isGlobe + } + + @computed get globeRotation(): [number, number] { + return this.manager.globeRotation ?? DEFAULT_VIEWPORT.rotation + } + + @computed get globeController(): GlobeController { + return this.manager.globeController ?? new GlobeController(this) } @action.bound onLegendMouseOver(bracket: MapBracket): void { @@ -318,7 +370,38 @@ export class MapChart } @action.bound onLegendMouseLeave(): void { - this.focusBracket = undefined + if (!this.persistFocusBracket) this.focusBracket = undefined + } + + @action.bound onLegendClick(bracket: MapBracket): void { + if (!this.isGlobe) return + this.focusBracket = bracket + this.persistFocusBracket = true + } + + @action.bound onGrapherClick(e: Event): void { + if (!this.isGlobe) return + + const target = e.target as HTMLElement + + let isWithinGlobe = false + if (this.base.current) { + const point = getRelativeMouse(this.base.current, e as MouseEvent) + const bounds = this.choroplethMapBounds + isWithinGlobe = PointVector.isPointInsideCircle(point, { + center: getGlobeCenter(bounds), + radius: getGlobeSize(bounds) / 2, + }) + } + + if ( + !this.manager.isModalOpen && + !isElementInteractive(target) && + !isWithinGlobe + ) { + this.focusBracket = undefined + this.persistFocusBracket = false + } } @computed get mapConfig(): MapConfig { @@ -327,6 +410,15 @@ export class MapChart @action.bound onProjectionChange(value: MapProjectionName): void { this.mapConfig.projection = value + void this.globeController.rotateToProjection(value) + } + + @action.bound clearProjection(): void { + this.mapConfig.projection = undefined + } + + @action.bound onGlobeRotationChange(rotate: [number, number]): void { + this.manager.globeRotation = rotate } @computed private get formatTooltipValue(): (d: PrimitiveType) => string { @@ -411,6 +503,13 @@ export class MapChart return (this as SVGPathElement).getAttribute("fill") }) } + if (this.manager.base?.current) { + this.manager.base.current.addEventListener( + "mousedown", + this.onGrapherClick, + { passive: true } + ) + } exposeInstanceOnWindow(this) } @@ -435,11 +534,13 @@ export class MapChart } @computed get choroplethMapBounds(): Bounds { - return this.bounds.padBottom(this.legendHeight + 4) + return this.bounds + .padTop(this.isGlobe ? 4 : 0) + .padBottom(this.legendHeight + 4) } @computed get projection(): MapProjectionName { - return this.mapConfig.projection + return this.mapConfig.projection ?? MapProjectionName.World } @computed get numericLegendData(): ColorScaleBin[] { @@ -643,11 +744,13 @@ class ChoroplethMap extends React.Component<{ }> { base: React.RefObject = React.createRef() - lastScreenX: number = 0 - lastScreenY: number = 0 - @observable - globeRotation: any - enableDragging: boolean = false + // If true selected countries will have an outline + @observable private showSelectedStyle = false + + private isDragging: boolean = false + + private previousScreenX: number | undefined = undefined + private previousScreenY: number | undefined = undefined @computed private get uid(): number { return guid() @@ -704,78 +807,18 @@ class ChoroplethMap extends React.Component<{ return this.choroplethData.get(id)!.isSelected } - // Viewport for each projection, defined by center and width+height in fractional coordinates - @computed private get viewport(): { - rotation: any - x: number - y: number - width: number - height: number - } { - const viewports = { - World: { - x: 0.565, - y: 0.5, - width: 1, - height: 1, - rotation: [30, 0], - }, - Europe: { - x: 0.53, - y: 0.22, - width: 0.2, - height: 0.2, - rotation: [-30, -50], - }, - Africa: { - x: 0.49, - y: 0.7, - width: 0.21, - height: 0.38, - rotation: [-30, 0], - }, - NorthAmerica: { - x: 0.49, - y: 0.4, - width: 0.19, - height: 0.32, - rotation: [70, -50], - }, - SouthAmerica: { - x: 0.52, - y: 0.815, - width: 0.1, - height: 0.26, - rotation: [70, 20], - }, - Asia: { - x: 0.75, - y: 0.45, - width: 0.3, - height: 0.5, - rotation: [-80, -20], - }, - Oceania: { - x: 0.51, - y: 0.75, - width: 0.1, - height: 0.2, - rotation: [-125, 30], - }, - } + @computed get isWorldProjection(): boolean { + return this.manager.projection === MapProjectionName.World + } - return viewports[this.manager.projection] + @computed private get viewport(): Viewport { + return VIEWPORTS[this.manager.projection] } // Calculate what scaling should be applied to the untransformed map to match the current viewport to the container @computed private get viewportScale(): number { const { bounds, viewport, mapBounds } = this - if (this.manager.isGlobe) { - return Math.min( - bounds.width / mapBounds.width, - bounds.height / mapBounds.height - ) - } + if (this.manager.isGlobe) return 1 const viewportWidth = viewport.width * mapBounds.width const viewportHeight = viewport.height * mapBounds.height return Math.min( @@ -784,48 +827,68 @@ class ChoroplethMap extends React.Component<{ ) } - @computed get globeWidth(): number { - return (this.bounds.width - 300) / 2 + @computed private get globeSize(): number { + return getGlobeSize(this.bounds) } - @computed get globeHeight(): number { - return (this.bounds.height - 150) / 2 + @computed private get globeCenter(): [number, number] { + const center = getGlobeCenter(this.bounds) + return [center.x, center.y] } - @computed get globeScale(): number { - if (this.bounds.height < this.bounds.width) { - return this.bounds.height / 2.3 - } else { - return this.bounds.width / 2.3 - } + @computed private get globeScale(): number { + const defaultScale = geoOrthographic().scale() + const defaultSize = 500 + return defaultScale * (this.globeSize / defaultSize) } - @computed get globeProjection(): any { - const worldView = this.manager.projection === MapProjectionName.World + @computed private get globeRotation(): [number, number] { + return this.manager.globeRotation + } - const scale = worldView ? this.globeScale : this.globeScale * 1.8 + @computed private get globeProjection(): any { return geoOrthographic() - ?.scale(scale) - .center([0, 0]) - .rotate( - worldView - ? this.globeRotation || [0, 0] - : this.viewport.rotation - ) - .translate([this.bounds.width / 2, this.bounds.height / 2]) + .scale(this.globeScale) + .translate(this.globeCenter) + .rotate(this.globeRotation) + } + + private globePathContext = new GeoPathRoundingContext() + @computed private get globePath(): GeoPath { + return geoPath() + .projection(this.globeProjection) + .context(this.globePathContext) } - getPath(feature: RenderFeature): string { + private getPath(feature: RenderFeature): string { if (this.manager.isGlobe) { - return geoPath().projection(this.globeProjection)(feature.geo) ?? "" + this.globePathContext.beginPath() + this.globePath(feature.geo) + return this.globePathContext.result() } else { return feature.path } } - @computed private get matrixTransform(): string { + @computed private get globeEquator(): string { + const equator = geoGraticule().step([0, 360])() + this.globePathContext.beginPath() + this.globePath(equator) + return this.globePathContext.result() + } + + @computed private get globeGraticule(): string { + const graticule = geoGraticule().step([10, 10])() + this.globePathContext.beginPath() + this.globePath(graticule) + return this.globePathContext.result() + } + + @computed private get matrixTransform(): string | undefined { const { bounds, mapBounds, viewport, viewportScale } = this + if (this.manager.isGlobe) return undefined + // Calculate our reference dimensions. These values are independent of the current // map translation and scaling. const mapX = mapBounds.x + 1 @@ -844,9 +907,6 @@ class ChoroplethMap extends React.Component<{ const newOffsetY = boundsCenterY - newCenterY const matrixStr = `matrix(${viewportScale},0,0,${viewportScale},${newOffsetX},${newOffsetY})` - if (this.manager.isGlobe) { - return `` - } return matrixStr } @@ -861,7 +921,7 @@ class ChoroplethMap extends React.Component<{ @computed private get featuresInProjection(): RenderFeature[] { const { projection } = this.manager const features = renderFeaturesFor(projection) - if (projection === MapProjectionName.World) return features + if (this.manager.isGlobe || this.isWorldProjection) return features return features.filter( (feature) => @@ -889,43 +949,107 @@ class ChoroplethMap extends React.Component<{ @computed private get quadtree(): Quadtree { return quadtree() - .x(({ center }) => center.x) - .y(({ center }) => center.y) + .x(({ center, geoCentroid }) => { + const globeCenter = this.globeProjection([ + geoCentroid.x, + geoCentroid.y, + ]) + return this.manager.isGlobe ? globeCenter[0] : center.x + }) + .y(({ center, geoCentroid }) => { + const globeCenter = this.globeProjection([ + geoCentroid.x, + geoCentroid.y, + ]) + return this.manager.isGlobe ? globeCenter[1] : center.y + }) .addAll(this.featuresInProjection) } - @observable private hoverEnterFeature?: RenderFeature - @observable private hoverNearbyFeature?: RenderFeature - @action.bound private onMouseMove(ev: React.MouseEvent): void { - if (this.enableDragging) { - requestAnimationFrame(() => { - const rotate = this.globeProjection.rotate() - const sensitivity = 0.8 - const rotateX = - rotate[0] + (ev.screenX - this.lastScreenX) * sensitivity - let rotateY = - rotate[1] - (ev.screenY - this.lastScreenY) * sensitivity - // https://github.com/owid/owid-grapher/pull/3057#discussion_r1459309897 - rotateY = Math.max(-90, Math.min(90, rotateY)) - - this.globeRotation = [rotateX, rotateY] - this.lastScreenX = ev.screenX - this.lastScreenY = ev.screenY - }) + @action.bound private startDragging(): void { + this.isDragging = true + this.manager.clearProjection() + document.body.style.cursor = "pointer" + } + + private stopDragTimerId: NodeJS.Timeout | undefined + @action.bound private stopDragging(): void { + if (this.stopDragTimerId) clearTimeout(this.stopDragTimerId) + // stop dragging after a short delay to silence click events + this.stopDragTimerId = setTimeout(() => { + this.isDragging = false + this.previousScreenX = undefined + this.previousScreenY = undefined + }, 100) + document.body.style.cursor = "default" + } + + private rotateFrameId: number | undefined + @action.bound private rotateGlobe( + startCoords: [number, number], + endCoords: [number, number] + ): void { + if (this.rotateFrameId) cancelAnimationFrame(this.rotateFrameId) + this.rotateFrameId = requestAnimationFrame(() => { + const dx = endCoords[0] - startCoords[0] + const dy = endCoords[1] - startCoords[1] + + const sensitivity = 0.7 + const [rx, ry] = this.globeProjection.rotate() + this.manager.onGlobeRotationChange([ + rx + dx * sensitivity, + clamp(ry - dy * sensitivity, -90, 90), + ]) + }) + } + + @action.bound private onCursorDrag(event: MouseEvent | TouchEvent): void { + if (!this.manager.isGlobe) return + + const { screenX, screenY } = getScreenCoords(event) + + // start dragging if this is the first move event + if ( + this.previousScreenX === undefined || + this.previousScreenY === undefined + ) { + this.startDragging() + + // init screen coords + this.previousScreenX = screenX + this.previousScreenY = screenY + } + + // dismiss the currently hovered feature + if (this.hoverEnterFeature || this.hoverNearbyFeature) { + this.hoverEnterFeature = undefined + this.hoverNearbyFeature = undefined + this.manager.onMapMouseLeave() } - if (ev.shiftKey) this.showSelectedStyle = true // Turn on highlight selection. To turn off, user can switch tabs. - if (this.hoverEnterFeature) return + // rotate globe from the previous screen coords to the current screen coords + this.rotateGlobe( + [this.previousScreenX, this.previousScreenY], + [screenX, screenY] + ) + // update screen coords + this.previousScreenX = screenX + this.previousScreenY = screenY + } + + @observable private hoverEnterFeature?: RenderFeature + @observable private hoverNearbyFeature?: RenderFeature + @action.bound private detectNearbyFeature( + event: MouseEvent | TouchEvent + ): void { + if (this.isDragging || this.hoverEnterFeature) return const subunits = this.base.current?.querySelector(".subunits") if (subunits) { - const { x, y } = getRelativeMouse(subunits, ev) + const { x, y } = getRelativeMouse(subunits, event) const distance = MAP_HOVER_TARGET_RANGE - let feature = null - if (!this.manager.isGlobe) { - feature = this.quadtree.find(x, y, distance) - } - if (!this.manager.isGlobe && feature) { + const feature = this.quadtree.find(x, y, distance) + if (feature) { if (feature.id !== this.hoverNearbyFeature?.id) { this.hoverNearbyFeature = feature this.manager.onMapMouseOver(feature.geo) @@ -937,12 +1061,76 @@ class ChoroplethMap extends React.Component<{ } else console.error("subunits was falsy") } - @action.bound private onMouseEnter(feature: RenderFeature): void { + @action.bound private onMouseDown(event: MouseEvent): void { + if (this.manager.isGlobe) { + event.preventDefault() // prevent text selection + + // register mousemove and mouseup events on the document + // so that dragging continues if the mouse leaves the map + document.addEventListener("mousemove", this.onCursorDrag, { + passive: true, + }) + document.addEventListener("mouseup", this.onMouseUp, { + passive: true, + }) + } + } + + @action.bound private onMouseUp(): void { + this.stopDragging() + + document.removeEventListener("mousemove", this.onCursorDrag) + document.removeEventListener("mouseup", this.onMouseUp) + } + + @action.bound private onMouseMove(event: MouseEvent): void { + if (event.shiftKey) this.showSelectedStyle = true // Turn on highlight selection. To turn off, user can switch tabs. + this.detectNearbyFeature(event) + } + + @action.bound private onTouchStart(event: TouchEvent): void { + this.detectNearbyFeature(event) + + if (this.base.current) { + this.base.current.addEventListener("touchmove", this.onTouchMove, { + passive: false, + }) + this.base.current.addEventListener("touchend", this.onTouchEnd, { + passive: true, + }) + this.base.current.addEventListener("touchcancel", this.onTouchEnd, { + passive: true, + }) + } + } + + @action.bound private onTouchMove(event: TouchEvent): void { + event.preventDefault() // prevent scrolling + this.onCursorDrag(event) + } + + @action.bound private onTouchEnd(): void { + this.stopDragging() + + if (this.base.current) { + this.base.current.removeEventListener("touchmove", this.onTouchMove) + this.base.current.removeEventListener("touchend", this.onTouchEnd) + this.base.current.removeEventListener( + "touchcancel", + this.onTouchEnd + ) + } + } + + @action.bound private onMouseEnterFeature(feature: RenderFeature): void { + // don't show tooltips when dragging + if (this.isDragging) return + this.hoverEnterFeature = feature this.manager.onMapMouseOver(feature.geo) } - @action.bound private onMouseLeave(): void { + @action.bound private onMouseLeaveFeature(): void { this.hoverEnterFeature = undefined this.manager.onMapMouseLeave() } @@ -951,13 +1139,99 @@ class ChoroplethMap extends React.Component<{ return this.hoverEnterFeature || this.hoverNearbyFeature } - @action.bound private onClick(ev: React.MouseEvent): void { - if (this.hoverFeature !== undefined) - this.manager.onClick(this.hoverFeature.geo, ev) + @computed private get isRotatedToLocalFeature(): boolean { + if (!this.localFeature) return false + return ( + -this.localFeature.geoCentroid.x === this.globeRotation[0] && + -this.localFeature.geoCentroid.y === this.globeRotation[1] + ) + } + + @computed private get isRotatedToDefault(): boolean { + return Object.values(DEFAULT_ROTATIONS).some( + ([defaultX, defaultY]) => + defaultX === this.globeRotation[0] && + defaultY === this.globeRotation[1] + ) } - // If true selected countries will have an outline - @observable private showSelectedStyle = false + @observable private localFeature?: RenderFeature + private async populateLocalEntityName(): Promise { + if (this.localFeature) return + try { + const localCountryInfo = await getUserCountryInformation() + if (localCountryInfo) { + const localFeature = this.featuresInProjection.find( + (f) => f.id === localCountryInfo.name + ) + if (localFeature) this.localFeature = localFeature + } + } catch (err) {} + } + + async componentDidMount(): Promise { + if (this.base.current) { + this.base.current.addEventListener("mousedown", this.onMouseDown, { + passive: false, + }) + this.base.current.addEventListener("mousemove", this.onMouseMove, { + passive: true, + }) + this.base.current.addEventListener( + "touchstart", + this.onTouchStart, + { passive: true } + ) + } + + if ( + this.globeRotation[0] !== DEFAULT_VIEWPORT.rotation[0] || + this.globeRotation[1] !== DEFAULT_VIEWPORT.rotation[1] + ) + return + + if (!this.isWorldProjection) { + this.manager.onGlobeRotationChange(this.viewport.rotation) + } else { + await this.populateLocalEntityName() + if (this.localFeature) { + const { geoCentroid } = this.localFeature + void this.manager.globeController.rotateTo([ + -geoCentroid.x, + -geoCentroid.y, + ]) + } else { + // if the user's country can't be detected, + // choose a default rotation based on the time of day + const date = new Date() + const hours = date.getUTCHours() + const minutes = date.getUTCMinutes() + let defaultRotation: [number, number] + if (hours <= 7 && minutes <= 59) { + defaultRotation = DEFAULT_ROTATIONS.UTC_MORNING + } else if (hours <= 15 && minutes <= 59) { + defaultRotation = DEFAULT_ROTATIONS.UTC_MIDDAY + } else { + defaultRotation = DEFAULT_ROTATIONS.UTC_EVENING + } + void this.manager.globeController.rotateTo(defaultRotation) + } + } + } + + componentWillUnmount(): void { + if (this.base.current) { + this.base.current.removeEventListener("mousedown", this.onMouseDown) + this.base.current.removeEventListener("mousemove", this.onMouseMove) + this.base.current.removeEventListener( + "touchstart", + this.onTouchStart + ) + } + + if (this.stopDragTimerId) clearTimeout(this.stopDragTimerId) + if (this.rotateFrameId) cancelAnimationFrame(this.rotateFrameId) + } // SVG layering is based on order of appearance in the element tree (later elements rendered on top) // The ordering here is quite careful @@ -991,27 +1265,66 @@ class ChoroplethMap extends React.Component<{ ref={this.base} className={CHOROPLETH_MAP_CLASSNAME} clipPath={clipPath.id} - onMouseDown={(ev: SVGMouseEvent): void => { - this.enableDragging = true - this.lastScreenX = ev.screenX - this.lastScreenY = ev.screenY - ev.preventDefault() /* Without this, title may get selected while shift clicking */ + style={{ + touchAction: this.manager.isGlobe + ? "pinch-zoom" + : undefined, }} - onMouseMove={this.onMouseMove} - onMouseLeave={this.onMouseLeave} - onMouseUp={() => { - this.enableDragging = false - }} - style={this.hoverFeature ? { cursor: "pointer" } : {}} > - + {this.manager.isGlobe ? ( + + ) : ( + + )} + {this.manager.isGlobe && ( + <> + + + + )} + {this.manager.isGlobe && + (this.isRotatedToLocalFeature || + this.isRotatedToDefault) && ( + + )} {clipPath.element} {featuresOutsideProjection.length > 0 && ( @@ -1049,6 +1362,7 @@ class ChoroplethMap extends React.Component<{ 1 / this.viewportScale })`} // <-- This scale here is crucial and map specific > + - this.manager.onClick( + onClick={(ev: SVGMouseEvent): void => { + if (this.isDragging) return + this.manager.onClickFeature( feature.geo, ev ) - } + }} onMouseEnter={(): void => - this.onMouseEnter(feature) + this.onMouseEnterFeature(feature) } - onMouseLeave={this.onMouseLeave} + onMouseLeave={this.onMouseLeaveFeature} /> ) })} @@ -1141,13 +1456,17 @@ class ChoroplethMap extends React.Component<{ cursor="pointer" fill={fill} fillOpacity={fillOpacity} - onClick={(ev: SVGMouseEvent): void => - this.manager.onClick(feature.geo, ev) - } + onClick={(ev: SVGMouseEvent): void => { + if (this.isDragging) return + this.manager.onClickFeature( + feature.geo, + ev + ) + }} onMouseEnter={(): void => - this.onMouseEnter(feature) + this.onMouseEnterFeature(feature) } - onMouseLeave={this.onMouseLeave} + onMouseLeave={this.onMouseLeaveFeature} /> ) }), @@ -1158,3 +1477,76 @@ class ChoroplethMap extends React.Component<{ ) } } + +const getScreenCoords = ( + event: MouseEvent | TouchEvent +): { screenX: number; screenY: number } => { + return isTouchEvent(event) + ? event.touches[0] + : { + screenX: event.screenX, + screenY: event.screenY, + } +} + +const isTouchEvent = (event: MouseEvent | TouchEvent): event is TouchEvent => { + return event.type.includes("touch") +} + +const getGlobeSize = (bounds: Bounds): number => { + return Math.min(bounds.width, bounds.height) +} + +const getGlobeCenter = (bounds: Bounds): PointVector => { + return new PointVector( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2 + ) +} + +function GlobeLocationInfo({ + bounds, + label, + fontSize, +}: { + bounds: Bounds + label: string + fontSize?: number +}): React.ReactElement { + return ( + +
+ + {label} +
+
+ ) +} diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChartConstants.ts b/packages/@ourworldindata/grapher/src/mapCharts/MapChartConstants.ts index 31c78760de7..82576d909b4 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChartConstants.ts @@ -11,6 +11,7 @@ import { import { ChartManager } from "../chart/ChartManager" import { MapConfig } from "./MapConfig" import { ChartSeries } from "../chart/ChartInterface" +import { GlobeController } from "./GlobeController" export type GeoFeature = GeoJSON.Feature export type MapBracket = ColorScaleBin @@ -40,15 +41,22 @@ export interface ChoroplethMapManager { noDataColor: string focusBracket?: MapBracket focusEntity?: MapEntity - onClick: (d: GeoFeature, ev: React.MouseEvent) => void + onClickFeature: (d: GeoFeature, ev: React.MouseEvent) => void onMapMouseOver: (d: GeoFeature) => void onMapMouseLeave: () => void + onProjectionChange: (projection: MapProjectionName) => void + clearProjection: () => void strokeWidth?: number + globeRotation: [number, number] + globeController: GlobeController + onGlobeRotationChange: (rotate: [number, number]) => void + fontSize?: number } export interface RenderFeature { id: string geo: GeoFeature + geoCentroid: PointVector // unprojected centroid path: string bounds: Bounds center: PointVector @@ -66,4 +74,79 @@ export interface MapChartManager extends ChartManager { mapConfig?: MapConfig endTime?: Time title?: string + isModalOpen?: boolean + globeRotation?: [number, number] + globeController?: GlobeController +} + +export interface Viewport { + x: number + y: number + width: number + height: number + rotation: [number, number] +} + +// Viewport for each projection, defined by center and width+height in fractional coordinates +export const VIEWPORTS: Record = { + World: { + x: 0.565, + y: 0.5, + width: 1, + height: 1, + rotation: [30, -20], // Atlantic ocean (i.e. Americas & Europe) + }, + Europe: { + x: 0.53, + y: 0.22, + width: 0.2, + height: 0.2, + rotation: [-10, -50], + }, + Africa: { + x: 0.49, + y: 0.7, + width: 0.21, + height: 0.38, + rotation: [-20, 0], + }, + NorthAmerica: { + x: 0.49, + y: 0.4, + width: 0.19, + height: 0.32, + rotation: [110, -40], + }, + SouthAmerica: { + x: 0.52, + y: 0.815, + width: 0.1, + height: 0.26, + rotation: [60, 20], + }, + Asia: { + x: 0.75, + y: 0.45, + width: 0.3, + height: 0.5, + rotation: [-100, -35], + }, + Oceania: { + x: 0.51, + y: 0.75, + width: 0.1, + height: 0.2, + rotation: [-140, 20], + }, +} + +export const DEFAULT_VIEWPORT = VIEWPORTS.World + +export const DEFAULT_ROTATIONS: Record< + "UTC_MORNING" | "UTC_MIDDAY" | "UTC_EVENING", + [number, number] +> = { + UTC_MORNING: [-110, -15], // Asia & Oceania + UTC_MIDDAY: [-20, -20], // Europe & Africa + UTC_EVENING: [90, -15], // North & South America } diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts b/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts index 9a11d0b740b..d802faf1135 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapConfig.ts @@ -23,7 +23,7 @@ class MapConfigDefaults { @observable timeTolerance?: number @observable toleranceStrategy?: ToleranceStrategy @observable hideTimeline?: boolean - @observable projection = MapProjectionName.World + @observable projection? = MapProjectionName.World @observable colorScale = new ColorScaleConfig() // Show the label from colorSchemeLabels in the tooltip instead of the numeric value diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index 4e9b3b9467f..dde060a0e64 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -45,14 +45,13 @@ import { round, difference, } from "@ourworldindata/utils" +import { isElementInteractive } from "../core/GrapherUtils" import { observer } from "mobx-react" import { NoDataModal } from "../noDataModal/NoDataModal" import { BASE_FONT_SIZE, GRAPHER_AXIS_LINE_WIDTH_DEFAULT, GRAPHER_AXIS_LINE_WIDTH_THICK, - GRAPHER_SIDE_PANEL_CLASS, - GRAPHER_TIMELINE_CLASS, } from "../core/GrapherConstants" import { OwidTable, @@ -761,16 +760,11 @@ export class ScatterPlotChart // click anywhere inside the Grapher frame to dismiss the current selection @action.bound onGrapherClick(e: Event): void { const target = e.target as HTMLElement - - // check if the target is an interactive element or contained within one - const selector = `a, button, input, .${GRAPHER_TIMELINE_CLASS}, .${GRAPHER_SIDE_PANEL_CLASS}` - const isTargetInteractive = target.closest(selector) !== null - if ( this.canAddCountry && !this.hoverColor && !this.manager.isModalOpen && - !isTargetInteractive && + !isElementInteractive(target) && this.hasInteractedWithChart ) { this.selectionArray.clearSelection() diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 1f28d685d7b..02f832de51a 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -34,9 +34,8 @@ import { GRAPHER_DARK_TEXT, GRAPHER_FONT_SCALE_9_6, GRAPHER_FONT_SCALE_10_5, - GRAPHER_TIMELINE_CLASS, - GRAPHER_SIDE_PANEL_CLASS, } from "../core/GrapherConstants" +import { isElementInteractive } from "../core/GrapherUtils" import { ScaleType, EntitySelectionMode, @@ -495,18 +494,13 @@ export class SlopeChart // click anywhere inside the Grapher frame to dismiss the current selection @action.bound onGrapherClick(e: Event): void { const target = e.target as HTMLElement - - // check if the target is an interactive element or contained within one - const selector = `a, button, input, .${GRAPHER_TIMELINE_CLASS}, .${GRAPHER_SIDE_PANEL_CLASS}` - const isTargetInteractive = target.closest(selector) !== null - if ( this.isEntitySelectionEnabled && this.hasInteractedWithChart && !this.hoverKey && !this.hoverColor && !this.manager.isModalOpen && - !isTargetInteractive + !isElementInteractive(target) ) { this.selectionArray.clearSelection() } diff --git a/packages/@ourworldindata/grapher/src/timeline/TimelineController.ts b/packages/@ourworldindata/grapher/src/timeline/TimelineController.ts index b9e3fbf5c8b..0a566846eeb 100644 --- a/packages/@ourworldindata/grapher/src/timeline/TimelineController.ts +++ b/packages/@ourworldindata/grapher/src/timeline/TimelineController.ts @@ -4,6 +4,7 @@ import { TimeBoundValue, findClosestTime, last, + delay, } from "@ourworldindata/utils" export interface TimelineManager { @@ -18,9 +19,6 @@ export interface TimelineManager { onPlay?: () => void } -const delay = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)) - export class TimelineController { manager: TimelineManager diff --git a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx index a04d8ba69a5..f1611818011 100644 --- a/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx +++ b/packages/@ourworldindata/grapher/src/verticalColorLegend/VerticalColorLegend.tsx @@ -1,4 +1,5 @@ import React from "react" +import cx from "classnames" import { sum, max, makeIdForHumanConsumption } from "@ourworldindata/utils" import { TextWrap } from "@ourworldindata/components" import { computed } from "mobx" @@ -6,6 +7,7 @@ import { observer } from "mobx-react" import { GRAPHER_FONT_SCALE_11_2, BASE_FONT_SIZE, + GRAPHER_LEGEND_CLASS, } from "../core/GrapherConstants" import { Color } from "@ourworldindata/types" @@ -146,7 +148,11 @@ export class VerticalColorLegend extends React.Component<{ title.render(x, y, { textProps: { fontWeight: 700 } })} {series.map((series, index) => { diff --git a/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts b/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts index 723cff4213a..ba5bb6a9230 100644 --- a/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts +++ b/packages/@ourworldindata/types/src/gdocTypes/ArchieMlComponents.ts @@ -44,6 +44,7 @@ export enum ChartControlKeyword { yLogLinearSelector = "yLogLinearSelector", mapProjectionMenu = "mapProjectionMenu", tableFilterToggle = "tableFilterToggle", + globeToggle = "globeToggle", } export enum ChartTabKeyword { diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 637d3ac826c..2b13979c05c 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -607,6 +607,7 @@ export interface GrapherQueryParams extends QueryParams { uniformYAxis?: string showSelectionOnlyInTable?: string showNoDataArea?: string + globe?: string } export interface LegacyGrapherQueryParams extends GrapherQueryParams { diff --git a/packages/@ourworldindata/utils/src/PointVector.ts b/packages/@ourworldindata/utils/src/PointVector.ts index 4c375dd5f18..e83f362c18c 100644 --- a/packages/@ourworldindata/utils/src/PointVector.ts +++ b/packages/@ourworldindata/utils/src/PointVector.ts @@ -94,4 +94,14 @@ export class PointVector { new PointVector(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y)) ) } + + static isPointInsideCircle( + p: PointVector, + circle: { center: PointVector; radius: number } + ): boolean { + return ( + (p.x - circle.center.x) ** 2 + (p.y - circle.center.y) ** 2 < + circle.radius ** 2 + ) + } } diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 36f9e072302..73b4525314b 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -589,7 +589,7 @@ export const fetchText = async (url: string): Promise => { const _getUserCountryInformation = async (): Promise< UserCountryInformation | undefined > => - await fetch("/detect-country") + await fetch("https://detect-country.owid.io/") .then((res) => res.json()) .then((res) => res.country) .catch(() => undefined) @@ -1911,3 +1911,6 @@ export function commafyNumber(value: number): string { export function isFiniteWithGuard(value: unknown): value is number { return isFinite(value as any) } + +export const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 0543eb9a396..567c415dd3f 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -126,6 +126,7 @@ export { roundDownToNearestHundred, commafyNumber, isFiniteWithGuard, + delay, } from "./Util.js" export { diff --git a/site/gdocs/components/Chart.tsx b/site/gdocs/components/Chart.tsx index 0dad75dd892..8a25eb89a1e 100644 --- a/site/gdocs/components/Chart.tsx +++ b/site/gdocs/components/Chart.tsx @@ -174,6 +174,9 @@ const mapKeywordToGrapherConfig = ( case ChartControlKeyword.tableFilterToggle: return { hideTableFilterToggle: false } + case ChartControlKeyword.globeToggle: + return { hideGlobeToggle: false } + // tabs case ChartTabKeyword.chart: