From 2348bcadb853337323938bc91128583df070ea50 Mon Sep 17 00:00:00 2001 From: Daniel Bachler Date: Fri, 3 Jan 2025 11:42:18 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Refactor=20grapher=20and=20extract?= =?UTF-8?q?=20most=20of=20the=20state=20into=20GrapherState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteServer/testPageRouter.tsx | 5 +- .../explorer/src/Explorer.jsdom.test.tsx | 26 +- .../@ourworldindata/explorer/src/Explorer.tsx | 73 +- .../grapher/src/chart/DimensionSlot.ts | 14 +- .../grapher/src/core/FetchingGrapher.tsx | 66 +- .../grapher/src/core/Grapher.jsdom.test.ts | 100 +- .../grapher/src/core/Grapher.tsx | 4628 +++++++++-------- .../grapher/src/core/GrapherUrl.ts | 12 +- .../core/GrapherWithChartTypes.jsdom.test.tsx | 12 +- .../grapher/src/dataTable/DataTable.sample.ts | 18 +- packages/@ourworldindata/grapher/src/index.ts | 6 +- .../grapher/src/mapCharts/MapChart.tsx | 1 + .../src/mapCharts/MapTooltip.jsdom.test.tsx | 6 +- .../scatterCharts/ScatterPlotChart.test.ts | 12 +- .../MarimekkoChart.jsdom.test.tsx | 10 +- .../src/testData/OwidTestData.sample.ts | 10 +- site/DataPageV2Content.tsx | 36 +- site/GrapherFigureView.tsx | 29 +- 18 files changed, 2598 insertions(+), 2466 deletions(-) diff --git a/adminSiteServer/testPageRouter.tsx b/adminSiteServer/testPageRouter.tsx index 93199b4c70a..4ffaf90a3c8 100644 --- a/adminSiteServer/testPageRouter.tsx +++ b/adminSiteServer/testPageRouter.tsx @@ -130,8 +130,8 @@ async function propsFromQueryParams( const page = params.page ? expectInt(params.page) : params.random - ? Math.floor(1 + Math.random() * 180) // Sample one of 180 pages. Some charts won't ever get picked but good enough. - : 1 + ? Math.floor(1 + Math.random() * 180) // Sample one of 180 pages. Some charts won't ever get picked but good enough. + : 1 const perPage = parseIntOrUndefined(params.perPage) ?? 20 const ids = parseIntArrayOrUndefined(params.ids) const datasetIds = parseIntArrayOrUndefined(params.datasetIds) @@ -802,7 +802,6 @@ getPlainRouteWithROTransaction( res.send(svg) } ) - testPageRouter.get("/explorers", async (req, res) => { let explorers = await explorerAdminServer.getAllPublishedExplorers() const viewProps = getViewPropsFromQueryParams(req.query) diff --git a/packages/@ourworldindata/explorer/src/Explorer.jsdom.test.tsx b/packages/@ourworldindata/explorer/src/Explorer.jsdom.test.tsx index e35fcf581c0..7eaa5d8de65 100755 --- a/packages/@ourworldindata/explorer/src/Explorer.jsdom.test.tsx +++ b/packages/@ourworldindata/explorer/src/Explorer.jsdom.test.tsx @@ -30,26 +30,28 @@ describe(Explorer, () => { explorer.onChangeChoice("Gas")("All GHGs (CO₂eq)") - if (explorer.grapher) explorer.grapher.tab = GRAPHER_TAB_OPTIONS.table + if (explorer.grapher?.grapherState) + explorer.grapher.grapherState.tab = GRAPHER_TAB_OPTIONS.table else throw Error("where's the grapher?") expect(explorer.queryParams.tab).toEqual("table") explorer.onChangeChoice("Gas")("CO₂") expect(explorer.queryParams.tab).toEqual("table") - explorer.grapher.tab = GRAPHER_TAB_OPTIONS.chart + explorer.grapher.grapherState.tab = GRAPHER_TAB_OPTIONS.chart }) it("switches to first tab if current tab does not exist in new view", () => { const explorer = element.instance() as Explorer expect(explorer.queryParams.tab).toBeUndefined() - if (explorer.grapher) explorer.grapher.tab = GRAPHER_TAB_OPTIONS.map + if (explorer.grapher?.grapherState) + explorer.grapher.grapherState.tab = GRAPHER_TAB_OPTIONS.map else throw Error("where's the grapher?") expect(explorer.queryParams.tab).toEqual("map") explorer.onChangeChoice("Gas")("All GHGs (CO₂eq)") - expect(explorer.grapher.tab).toEqual("chart") + expect(explorer.grapher?.grapherState.tab).toEqual("chart") expect(explorer.queryParams.tab).toEqual(undefined) }) @@ -85,10 +87,10 @@ describe("inline data explorer", () => { expect(explorer.queryParams).toMatchObject({ Test: "Scatter", }) - expect(explorer.grapher?.xSlug).toEqual("x") - expect(explorer.grapher?.ySlugs).toEqual("y") - expect(explorer.grapher?.colorSlug).toEqual("color") - expect(explorer.grapher?.sizeSlug).toEqual("size") + expect(explorer.grapher?.grapherState?.xSlug).toEqual("x") + expect(explorer.grapher?.grapherState?.ySlugs).toEqual("y") + expect(explorer.grapher?.grapherState?.colorSlug).toEqual("color") + expect(explorer.grapher?.grapherState?.sizeSlug).toEqual("size") }) it("clears column slugs that don't exist in current row", () => { @@ -96,9 +98,9 @@ describe("inline data explorer", () => { expect(explorer.queryParams).toMatchObject({ Test: "Line", }) - expect(explorer.grapher?.xSlug).toEqual(undefined) - expect(explorer.grapher?.ySlugs).toEqual("y") - expect(explorer.grapher?.colorSlug).toEqual(undefined) - expect(explorer.grapher?.sizeSlug).toEqual(undefined) + expect(explorer.grapher?.grapherState?.xSlug).toEqual(undefined) + expect(explorer.grapher?.grapherState?.ySlugs).toEqual("y") + expect(explorer.grapher?.grapherState?.colorSlug).toEqual(undefined) + expect(explorer.grapher?.grapherState?.sizeSlug).toEqual(undefined) }) }) diff --git a/packages/@ourworldindata/explorer/src/Explorer.tsx b/packages/@ourworldindata/explorer/src/Explorer.tsx index 17d13748a44..f214022a71e 100644 --- a/packages/@ourworldindata/explorer/src/Explorer.tsx +++ b/packages/@ourworldindata/explorer/src/Explorer.tsx @@ -27,6 +27,7 @@ import { SlideShowManager, DEFAULT_GRAPHER_ENTITY_TYPE, GrapherAnalytics, + GrapherState, FocusArray, } from "@ourworldindata/grapher" import { @@ -195,15 +196,23 @@ export class Explorer GrapherManager { analytics = new GrapherAnalytics() + grapherState: GrapherState constructor(props: ExplorerProps) { super(props) this.explorerProgram = ExplorerProgram.fromJson( props ).initDecisionMatrix(this.initialQueryParams) - this.grapher = new Grapher({ - bounds: props.bounds, + this.grapherState = new GrapherState({ staticBounds: props.staticBounds, + bounds: props.bounds, + enableKeyboardShortcuts: true, + manager: this, + isEmbeddedInAnOwidPage: this.props.isEmbeddedInAnOwidPage, + adminBaseUrl: this.adminBaseUrl, + }) + this.grapher = new Grapher({ + grapherState: this.grapherState, }) } // caution: do a ctrl+f to find untyped usages @@ -332,7 +341,7 @@ export class Explorer if (this.props.isInStandalonePage) this.setCanonicalUrl() - this.grapher?.populateFromQueryParams(url.queryParams) + this.grapher?.grapherState?.populateFromQueryParams(url.queryParams) exposeInstanceOnWindow(this, "explorer") this.setUpIntersectionObserver() @@ -353,7 +362,7 @@ export class Explorer this.explorerProgram.indexViewsSeparately && document.location.search ) { - document.title = `${this.grapher.displayTitle} - Our World in Data` + document.title = `${this.grapher?.grapherState.displayTitle} - Our World in Data` } } @@ -431,7 +440,7 @@ export class Explorer return // todo: can we remove this? this.initSlideshow() - const oldGrapherParams = this.grapher.changedParams + const oldGrapherParams = this.grapher?.grapherState.changedParams this.persistedGrapherQueryParamsBySelectedRow.set( oldSelectedRow, oldGrapherParams @@ -443,23 +452,26 @@ export class Explorer ), country: oldGrapherParams.country, region: oldGrapherParams.region, - time: this.grapher.timeParam, + time: this.grapher?.grapherState.timeParam, } - const previousTab = this.grapher.activeTab + const previousTab = this.grapher?.grapherState.activeTab this.updateGrapherFromExplorer() - if (this.grapher.availableTabs.includes(previousTab)) { + if (this.grapher?.grapherState.availableTabs.includes(previousTab)) { // preserve the previous tab if that's still available in the new view newGrapherParams.tab = - this.grapher.mapGrapherTabToQueryParam(previousTab) - } else if (this.grapher.validChartTypes.length > 0) { + this.grapher?.grapherState.mapGrapherTabToQueryParam( + previousTab + ) + } else if (this.grapher?.grapherState.validChartTypes.length > 0) { // otherwise, switch to the first chart tab - newGrapherParams.tab = this.grapher.mapGrapherTabToQueryParam( - this.grapher.validChartTypes[0] - ) - } else if (this.grapher.hasMapTab) { + newGrapherParams.tab = + this.grapher?.grapherState.mapGrapherTabToQueryParam( + this.grapher?.grapherState.validChartTypes[0] + ) + } else if (this.grapher?.grapherState.hasMapTab) { // or switch to the map, if there is one newGrapherParams.tab = GRAPHER_TAB_QUERY_PARAMS.map } else { @@ -467,7 +479,7 @@ export class Explorer newGrapherParams.tab = GRAPHER_TAB_QUERY_PARAMS.table } - this.grapher.populateFromQueryParams(newGrapherParams) + this.grapher?.grapherState.populateFromQueryParams(newGrapherParams) this.analytics.logExplorerView( this.explorerProgram.slug, @@ -477,7 +489,7 @@ export class Explorer @action.bound private setGrapherTable(table: OwidTable) { if (this.grapher) { - this.grapher.inputTable = table + this.grapher.grapherState.inputTable = table this.grapher.appendNewEntitySelectionOptions() } } @@ -575,9 +587,9 @@ export class Explorer config.selectedEntityNames = this.selection.selectedEntityNames } - grapher.setAuthoredVersion(config) + grapher?.grapherState.setAuthoredVersion(config) grapher.reset() - grapher.updateFromObject(config) + grapher?.grapherState.updateFromObject(config) // grapher.downloadData() } @@ -739,9 +751,9 @@ export class Explorer return table } - grapher.setAuthoredVersion(config) + grapher?.grapherState.setAuthoredVersion(config) grapher.reset() - grapher.updateFromObject(config) + grapher?.grapherState.updateFromObject(config) if (dimensions.length === 0) { // If dimensions are empty, explicitly set the table to an empty table // so we don't end up confusingly showing stale data from a previous chart @@ -772,9 +784,9 @@ export class Explorer config.selectedEntityNames = this.selection.selectedEntityNames } - grapher.setAuthoredVersion(config) + grapher?.grapherState.setAuthoredVersion(config) grapher.reset() - grapher.updateFromObject(config) + grapher?.grapherState.updateFromObject(config) // Clear any error messages, they are likely to be related to dataset loading. this.grapher?.clearErrors() @@ -808,7 +820,7 @@ export class Explorer let url = Url.fromQueryParams( omitUndefinedValues({ - ...this.grapher.changedParams, + ...this.grapher?.grapherState.changedParams, pickerSort: this.entityPickerSort, pickerMetric: this.entityPickerMetric, hideControls: this.initialQueryParams.hideControls || undefined, @@ -1029,16 +1041,7 @@ export class Explorer this.isNarrow && this.mobileCustomizeButton}
- +
) @@ -1079,7 +1082,7 @@ export class Explorer } @computed get grapherTable() { - return this.grapher?.tableAfterAuthorTimelineFilter + return this.grapher?.grapherState?.tableAfterAuthorTimelineFilter } @observable entityPickerMetric? = this.initialQueryParams.pickerMetric @@ -1194,6 +1197,6 @@ export class Explorer } @computed get requiredColumnSlugs() { - return this.grapher?.newSlugs ?? [] + return this.grapher?.grapherState?.newSlugs ?? [] } } diff --git a/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts b/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts index 15013b4e7fe..f57eb7b1828 100644 --- a/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts +++ b/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts @@ -1,21 +1,21 @@ // todo: remove -import { Grapher } from "../core/Grapher" +import { Grapher, GrapherState } from "../core/Grapher" import { computed } from "mobx" import { ChartDimension } from "./ChartDimension" import { DimensionProperty } from "@ourworldindata/utils" export class DimensionSlot { - private grapher: Grapher + private grapherState: GrapherState property: DimensionProperty - constructor(grapher: Grapher, property: DimensionProperty) { - this.grapher = grapher + constructor(grapher: GrapherState, property: DimensionProperty) { + this.grapherState = grapher this.property = property } @computed get name(): string { const names = { - y: this.grapher.isDiscreteBar ? "X axis" : "Y axis", + y: this.grapherState.isDiscreteBar ? "X axis" : "Y axis", x: "X axis", size: "Size", color: "Color", @@ -28,7 +28,7 @@ export class DimensionSlot { @computed get allowMultiple(): boolean { return ( this.property === DimensionProperty.y && - this.grapher.supportsMultipleYColumns + this.grapherState.supportsMultipleYColumns ) } @@ -37,7 +37,7 @@ export class DimensionSlot { } @computed get dimensions(): ChartDimension[] { - return this.grapher.dimensions.filter( + return this.grapherState.dimensions.filter( (d) => d.property === this.property ) } diff --git a/packages/@ourworldindata/grapher/src/core/FetchingGrapher.tsx b/packages/@ourworldindata/grapher/src/core/FetchingGrapher.tsx index 32fbd477377..3825664a8a8 100644 --- a/packages/@ourworldindata/grapher/src/core/FetchingGrapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/FetchingGrapher.tsx @@ -4,7 +4,7 @@ import { OwidVariableDataMetadataDimensions, } from "@ourworldindata/types" import React from "react" -import { Grapher } from "./Grapher.js" +import { Grapher, GrapherState } from "./Grapher.js" import { loadVariableDataAndMetadata } from "./loadVariable.js" import { legacyToOwidTableAndDimensions } from "./LegacyToOwidTable.js" import { OwidTable } from "@ourworldindata/core-table" @@ -23,12 +23,18 @@ export function FetchingGrapher( // if config is not provided, fetch it from configUrl console.log("FetchingGrapher") - const [config, setConfig] = React.useState( - props.config + const [config, setConfig] = React.useState( + props.config ?? {} ) - const [inputTable, setInputTable] = React.useState( - undefined + const [grapherState, setGrapherState] = React.useState( + new GrapherState({ + ...config, + queryStr: props.queryString, + dataApiUrl: props.dataApiUrl, + adminBaseUrl: props.adminBaseUrl, + bakedGrapherURL: props.bakedGrapherURL, + }) ) React.useEffect(() => { @@ -42,34 +48,40 @@ export function FetchingGrapher( } console.log("fetchConfigAndLoadData: config", config) if (!config) return - const dimensions = config.dimensions || [] - if (dimensions.length === 0) return - const variables = dimensions.map((d) => d.variableId) - const variablesDataMap = await loadVariablesDataSite( - variables, + const inputTable = await fetchInputTableForConfig( + config, props.dataApiUrl ) - const inputTable = legacyToOwidTableAndDimensions( - variablesDataMap, - dimensions - ) - console.log("setting input table") - setInputTable(inputTable) + if (inputTable) grapherState.inputTable = inputTable } void fetchConfigAndLoadData() - }, [props.configUrl, config, props.dataApiUrl]) + }, [ + props.configUrl, + config, + props.dataApiUrl, + props.queryString, + props.adminBaseUrl, + props.bakedGrapherURL, + grapherState, + ]) + + return +} - if (!config) return null - if (!inputTable) return null - return ( - +export async function fetchInputTableForConfig( + config: GrapherInterface, + dataApiUrl: string +): Promise { + const dimensions = config.dimensions || [] + if (dimensions.length === 0) return undefined + const variables = dimensions.map((d) => d.variableId) + const variablesDataMap = await loadVariablesDataSite(variables, dataApiUrl) + const inputTable = legacyToOwidTableAndDimensions( + variablesDataMap, + dimensions ) + + return inputTable } // async function loadVariablesDataAdmin( diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts index dba594860e5..091dbf1da81 100755 --- a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts +++ b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts @@ -1,5 +1,9 @@ #! /usr/bin/env jest -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "../core/Grapher" import { GRAPHER_CHART_TYPES, EntitySelectionMode, @@ -61,7 +65,7 @@ const TestGrapherConfig = (): { } it("regression fix: container options are not serialized", () => { - const grapher = new Grapher({ xAxis: { min: 1 } }) + const grapher = new GrapherState({ xAxis: { min: 1 } }) const obj = grapher.toObject().xAxis! expect(obj.min).toBe(1) expect(obj.scaleType).toBe(undefined) @@ -69,7 +73,7 @@ it("regression fix: container options are not serialized", () => { }) it("can get dimension slots", () => { - const grapher = new Grapher() + const grapher = new GrapherState({}) expect(grapher.dimensionSlots.length).toBe(2) grapher.chartTypes = [GRAPHER_CHART_TYPES.ScatterPlot] @@ -77,7 +81,7 @@ it("can get dimension slots", () => { }) it("an empty Grapher serializes to an object that includes only the schema", () => { - expect(new Grapher().toObject()).toEqual({ + expect(new GrapherState({}).toObject()).toEqual({ $schema: latestGrapherConfigSchema, }) }) @@ -86,14 +90,16 @@ it("a bad chart type does not crash grapher", () => { const input = { chartTypes: ["fff" as any], } - expect(new Grapher(input).toObject()).toEqual({ + expect(new GrapherState(input).toObject()).toEqual({ ...input, $schema: latestGrapherConfigSchema, }) }) it("does not preserve defaults in the object (except for the schema)", () => { - expect(new Grapher({ tab: GRAPHER_TAB_OPTIONS.chart }).toObject()).toEqual({ + expect( + new GrapherState({ tab: GRAPHER_TAB_OPTIONS.chart }).toObject() + ).toEqual({ $schema: latestGrapherConfigSchema, }) }) @@ -146,7 +152,7 @@ const legacyConfig: Omit & } it("can apply legacy chart dimension settings", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) const col = grapher.yColumnsFromDimensions[0]! expect(col.unit).toEqual(unit) expect(col.displayName).toEqual(name) @@ -154,7 +160,7 @@ it("can apply legacy chart dimension settings", () => { it("correctly identifies changes to passed-in selection", () => { const selection = new SelectionArray() - const grapher = new Grapher({ + const grapher = new GrapherState({ ...legacyConfig, manager: { selection }, }) @@ -173,7 +179,7 @@ it("can fallback to a ycolumn if a map variableId does not exist", () => { hasMapTab: true, map: { variableId: 444 }, } as GrapherInterface - const grapher = new Grapher(config) + const grapher = new GrapherState(config) expect(grapher.mapColumnSlug).toEqual("3512") }) @@ -182,7 +188,7 @@ it("can generate a url with country selection even if there is no entity code", ...legacyConfig, selectedEntityNames: [], } - const grapher = new Grapher(config) + const grapher = new GrapherState(config) expect(grapher.queryStr).toBe("") grapher.selection.selectAll() expect(grapher.queryStr).toContain("AFG") @@ -194,7 +200,7 @@ it("can generate a url with country selection even if there is no entity code", metadata.dimensions.entities.values.find( (entity) => entity.id === 15 )!.code = undefined as any - const grapher2 = new Grapher(config2) + const grapher2 = new GrapherState(config2) expect(grapher2.queryStr).toBe("") grapher2.selection.selectAll() expect(grapher2.queryStr).toContain("AFG") @@ -202,7 +208,7 @@ it("can generate a url with country selection even if there is no entity code", describe("hasTimeline", () => { it("charts with timeline", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) grapher.chartTypes = [GRAPHER_CHART_TYPES.LineChart] expect(grapher.hasTimeline).toBeTruthy() grapher.chartTypes = [GRAPHER_CHART_TYPES.SlopeChart] @@ -216,7 +222,7 @@ describe("hasTimeline", () => { }) it("map tab has timeline even if chart doesn't", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) grapher.hideTimeline = true grapher.chartTypes = [GRAPHER_CHART_TYPES.LineChart] expect(grapher.hasTimeline).toBeFalsy() @@ -227,8 +233,8 @@ describe("hasTimeline", () => { }) }) -const getGrapher = (): Grapher => - new Grapher({ +const getGrapher = (): GrapherState => + new GrapherState({ dimensions: [ { variableId: 142609, @@ -286,8 +292,8 @@ const getGrapher = (): Grapher => function fromQueryParams( params: LegacyGrapherQueryParams, props?: Partial -): Grapher { - const grapher = new Grapher(props) +): GrapherState { + const grapher = new GrapherState(props ?? {}) grapher.populateFromQueryParams( legacyToCurrentGrapherQueryParams(queryParamsToStr(params)) ) @@ -297,7 +303,7 @@ function fromQueryParams( function toQueryParams( props?: Partial ): Partial { - const grapher = new Grapher({ + const grapher = new GrapherState({ minTime: -5000, maxTime: 5000, map: { time: 5000 }, @@ -307,8 +313,8 @@ function toQueryParams( } it("can serialize scaleType if it changes", () => { - expect(new Grapher().changedParams.xScale).toEqual(undefined) - const grapher = new Grapher({ + expect(new GrapherState({}).changedParams.xScale).toEqual(undefined) + const grapher = new GrapherState({ xAxis: { scaleType: ScaleType.linear }, }) expect(grapher.changedParams.xScale).toEqual(undefined) @@ -322,7 +328,7 @@ describe("currentTitle", () => { { entityCount: 2, timeRange: [2000, 2010] }, 1 ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, selectedEntityNames: table.availableEntityNames, dimensions: [ @@ -357,7 +363,7 @@ describe("currentTitle", () => { { entityCount: 2, timeRange: [2000, 2010] }, 1 ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, ySlugs: "GDP", }) @@ -369,7 +375,7 @@ describe("currentTitle", () => { describe("authors can use maxTime", () => { it("can can create a discretebar chart with correct maxtime", () => { const table = SynthesizeGDPTable({ timeRange: [2000, 2010] }) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.DiscreteBar], selectedEntityNames: table.availableEntityNames, @@ -382,7 +388,7 @@ describe("authors can use maxTime", () => { }) describe("line chart to bar chart and bar chart race", () => { - const grapher = new Grapher(TestGrapherConfig()) + const grapher = new GrapherState(TestGrapherConfig()) it("can create a new line chart with different start and end times", () => { expect( @@ -394,7 +400,7 @@ describe("line chart to bar chart and bar chart race", () => { }) describe("switches from a line chart to a bar chart when there is only 1 year selected", () => { - const grapher = new Grapher(TestGrapherConfig()) + const grapher = new GrapherState(TestGrapherConfig()) const lineSeries = grapher.chartInstance.series expect( @@ -455,7 +461,7 @@ describe("line chart to bar chart and bar chart race", () => { describe("urls", () => { it("can change base url", () => { - const url = new Grapher({ + const url = new GrapherState({ isPublished: true, slug: "foo", bakedGrapherURL: "/grapher", @@ -464,13 +470,13 @@ describe("urls", () => { }) it("does not include country param in url if unchanged", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) grapher.isPublished = true expect(grapher.canonicalUrl?.includes("country")).toBeFalsy() }) it("includes the tab param in embed url even if it's the default value", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ isPublished: true, slug: "foo", bakedGrapherURL: "/grapher", @@ -491,7 +497,7 @@ describe("urls", () => { }) it("doesn't apply selection if addCountryMode is 'disabled'", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ selectedEntityNames: ["usa", "canada"], addCountryMode: EntitySelectionMode.Disabled, }) @@ -503,19 +509,19 @@ describe("urls", () => { }) it("parses tab=table correctly", () => { - const grapher = new Grapher() + const grapher = new GrapherState({}) grapher.populateFromQueryParams({ tab: "table" }) expect(grapher.activeTab).toEqual(GRAPHER_TAB_NAMES.Table) }) it("parses tab=map correctly", () => { - const grapher = new Grapher() + const grapher = new GrapherState({}) grapher.populateFromQueryParams({ tab: "map" }) expect(grapher.activeTab).toEqual(GRAPHER_TAB_NAMES.WorldMap) }) it("parses tab=chart correctly", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], }) grapher.populateFromQueryParams({ tab: "chart" }) @@ -523,7 +529,7 @@ describe("urls", () => { }) it("parses tab=line and tab=slope correctly", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [ GRAPHER_CHART_TYPES.LineChart, GRAPHER_CHART_TYPES.SlopeChart, @@ -536,7 +542,7 @@ describe("urls", () => { }) it("switches to the first chart tab if the given chart isn't available", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [ GRAPHER_CHART_TYPES.LineChart, GRAPHER_CHART_TYPES.SlopeChart, @@ -547,19 +553,19 @@ describe("urls", () => { }) it("switches to the map tab if no chart is available", () => { - const grapher = new Grapher({ chartTypes: [], hasMapTab: true }) + const grapher = new GrapherState({ chartTypes: [], hasMapTab: true }) grapher.populateFromQueryParams({ tab: "line" }) expect(grapher.activeTab).toEqual(GRAPHER_TAB_NAMES.WorldMap) }) it("switches to the table tab if it's the only tab available", () => { - const grapher = new Grapher({ chartTypes: [] }) + const grapher = new GrapherState({ chartTypes: [] }) grapher.populateFromQueryParams({ tab: "line" }) expect(grapher.activeTab).toEqual(GRAPHER_TAB_NAMES.Table) }) it("adds tab=chart to the URL if there is a single chart tab", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ hasMapTab: true, tab: GRAPHER_TAB_OPTIONS.map, }) @@ -568,7 +574,7 @@ describe("urls", () => { }) it("adds the chart type name as tab query param if there are multiple chart tabs", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [ GRAPHER_CHART_TYPES.LineChart, GRAPHER_CHART_TYPES.SlopeChart, @@ -587,7 +593,7 @@ describe("time domain tests", () => { { entityCount: 2, timeRange: [2000, 2010] }, seed ).replaceRandomCells(17, [SampleColumnSlugs.GDP], seed) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, selectedEntityNames: table.availableEntityNames, dimensions: [ @@ -716,7 +722,7 @@ describe("time parameter", () => { }) it("doesn't include URL param if it's identical to original config", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ minTime: 0, maxTime: 75, }) @@ -724,7 +730,7 @@ describe("time parameter", () => { }) it("doesn't include URL param if unbounded is encoded as `undefined`", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ minTime: undefined, maxTime: 75, }) @@ -863,7 +869,7 @@ describe("time parameter", () => { it("canChangeEntity reflects all available entities before transforms", () => { const table = SynthesizeGDPTable() - const grapher = new Grapher({ + const grapher = new GrapherState({ addCountryMode: EntitySelectionMode.SingleEntity, table, selectedEntityNames: table.sampleEntityName(1), @@ -986,7 +992,7 @@ it("correctly identifies activeColumnSlugs", () => { new OwidTable(`entityName,entityId,entityColor,year,gdp,gdp-annotations,child_mortality,population,continent,happiness Belgium,BEL,#f6f,2010,80000,pretty damn high,1.5,9000000,Europe,81.2 `) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "gdp", @@ -1023,7 +1029,7 @@ it("considers map tolerance before using column tolerance", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, ySlugs: "gdp", tab: GRAPHER_TAB_OPTIONS.map, @@ -1055,7 +1061,7 @@ describe("tableForSelection", () => { it("should include all available entities (LineChart)", () => { const table = SynthesizeGDPTable({ entityNames: ["A", "B"] }) - const grapher = new Grapher({ table }) + const grapher = new GrapherState({ table }) expect(grapher.tableForSelection.availableEntityNames).toEqual([ "A", @@ -1084,7 +1090,7 @@ describe("tableForSelection", () => { [4, "France", "", 2000, 0, null, null, null], // y value missing ]) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], excludedEntities: [3], @@ -1120,7 +1126,7 @@ it("handles tolerance when there are gaps in ScatterPlot data", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 156ff6938a4..fa09ed7f95a 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -306,1556 +306,1631 @@ export interface GrapherManager { editUrl?: string } -export class GrapherState {} - -@observer -export class Grapher - extends React.Component - implements - TimelineManager, - ChartManager, - AxisManager, - CaptionedChartManager, - SourcesModalManager, - DownloadModalManager, - DiscreteBarChartManager, - LegacyDimensionsManager, - ShareMenuManager, - EmbedModalManager, - TooltipManager, - DataTableManager, - ScatterPlotManager, - MarimekkoChartManager, - FacetChartManager, - EntitySelectorModalManager, - SettingsMenuManager, - MapChartManager, - SlopeChartManager -{ - // #region SortConfig props - @observable sortBy?: SortBy = SortBy.total - @observable sortOrder?: SortOrder = SortOrder.desc - @observable sortColumnSlug?: string - // #endregion - - // #region GrapherInterface props - @observable.ref $schema = latestGrapherConfigSchema - @observable.ref chartTypes: GrapherChartType[] = [ - GRAPHER_CHART_TYPES.LineChart, - ] - @observable.ref id?: number = undefined - @observable.ref version = 1 - @observable.ref slug?: string = undefined - - // Initializing text fields with `undefined` ensures that empty strings get serialised - @observable.ref title?: string = undefined - @observable.ref subtitle: string | undefined = undefined - @observable.ref sourceDesc?: string = undefined - @observable.ref note?: string = undefined - @observable hideAnnotationFieldsInTitle?: AnnotationFieldsInTitle = - undefined - - @observable.ref minTime?: TimeBound = undefined - @observable.ref maxTime?: TimeBound = undefined - @observable.ref timelineMinTime?: Time = undefined - @observable.ref timelineMaxTime?: Time = undefined - @observable.ref dimensions: ChartDimension[] = [] - @observable.ref addCountryMode = EntitySelectionMode.MultipleEntities - @observable comparisonLines?: ComparisonLineConfig[] = undefined // todo: Persistables? - @observable.ref stackMode = StackMode.absolute - @observable.ref showNoDataArea = true - @observable.ref hideLegend?: boolean = false - @observable.ref logo?: LogoOption = undefined - @observable.ref hideLogo?: boolean = undefined - @observable.ref hideRelativeToggle? = true - @observable.ref entityType = DEFAULT_GRAPHER_ENTITY_TYPE - @observable.ref entityTypePlural = DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL - @observable.ref hideTimeline?: boolean = undefined - @observable.ref zoomToSelection?: boolean = undefined - @observable.ref showYearLabels?: boolean = undefined // Always show year in labels for bar charts - @observable.ref hasMapTab = false - @observable.ref tab: GrapherTabOption = GRAPHER_TAB_OPTIONS.chart - @observable relatedQuestions?: RelatedQuestionsConfig[] = undefined // todo: Persistables? - // Missing from GrapherInterface: details - @observable.ref internalNotes?: string = undefined - @observable.ref variantName?: string = undefined - @observable.ref originUrl?: string = undefined - @observable.ref isPublished?: boolean = undefined - @observable.ref baseColorScheme?: ColorSchemeName = undefined - @observable.ref invertColorScheme?: boolean = undefined - @observable hideConnectedScatterLines?: boolean = undefined // Hides lines between points when timeline spans multiple years. Requested by core-econ for certain charts - @observable.ref hideScatterLabels?: boolean = undefined - @observable - scatterPointLabelStrategy?: ScatterPointLabelStrategy = undefined - @observable.ref compareEndPointsOnly?: boolean = undefined - @observable.ref matchingEntitiesOnly?: boolean = undefined - /** Hides the total value label that is normally displayed for stacked bar charts */ - @observable.ref hideTotalValueLabel?: boolean = undefined - @observable excludedEntities?: number[] = undefined - /** IncludedEntities are usually empty which means use all available entities. When - includedEntities is set it means "only use these entities". excludedEntities - are evaluated afterwards and can still remove entities even if they were included before. - */ - @observable includedEntities?: number[] = undefined - @observable selectedEntityNames: EntityName[] = [] - @observable selectedEntityColors: { - [entityName: string]: string | undefined - } = {} - - @observable focusedSeriesNames: SeriesName[] = [] - @observable.ref missingDataStrategy?: MissingDataStrategy = undefined - @observable.ref hideFacetControl?: boolean = undefined - @observable.ref facettingLabelByYVariables = "metric" - // the desired faceting strategy, which might not be possible if we change the data - @observable selectedFacetStrategy?: FacetStrategy = undefined - - @observable.ref xAxis = new AxisConfig(undefined, this) - @observable.ref yAxis = new AxisConfig(undefined, this) - @observable colorScale = new ColorScaleConfig() - @observable map = new MapConfig() - - @observable ySlugs?: ColumnSlugs = undefined - @observable xSlug?: ColumnSlug = undefined - @observable sizeSlug?: ColumnSlug = undefined - @observable colorSlug?: ColumnSlug = undefined - @observable tableSlugs?: ColumnSlugs = undefined - - // #endregion GrapherInterface properties - - // #region GrapherProgrammaticInterface props - - owidDataset?: MultipleOwidVariableDataDimensionsMap = undefined // This is used for passing data for testing - manuallyProvideData? = false // This will be removed. - @computed get queryStr(): string { - return queryParamsToStr({ - ...this.changedParams, - ...this.externalQueryParams, - }) - } - // bounds defined in interface but not on Grapher - @computed get table(): OwidTable { - return this.tableAfterAuthorTimelineFilter - } - - @observable bakedGrapherURL = this.props.bakedGrapherURL - adminBaseUrl = this.props.adminBaseUrl - dataApiUrl = - this.props.dataApiUrl ?? "https://api.ourworldindata.org/v1/indicators/" - // env defined in interface but not on Grapher - @computed get dataApiUrlForAdmin(): string | undefined { - return this.props.dataApiUrlForAdmin - } - /** - * Used to highlight an entity at a particular time in a line chart. - * The sparkline in map tooltips makes use of this. - */ - @observable.ref entityYearHighlight?: EntityYearHighlight = undefined - - @computed get baseFontSize(): number { - if (this.isStaticAndSmall) { - return this.computeBaseFontSizeFromHeight(this.staticBounds) +// export interface GrapherStateInitOptions { +// bakedGrapherURL?: string +// adminBaseUrl?: string +// dataApiUrl?: string +// dataApiUrlForAdmin?: string +// staticBounds?: Bounds // assing this or call getStaticBounds +// staticFormat?: GrapherStaticFormat +// env?: string +// isEmbeddedInAnOwidPage?: boolean +// isEmbeddedInADataPage?: boolean +// manager?: GrapherManager +// queryStr?: string +// inputTable?: OwidTable +// selectedEntityNames?: EntityName[] +// bounds?: Bounds +// } + +export class GrapherState { + constructor(options: GrapherProgrammaticInterface) { + // prefer the manager's selection over the config's selectedEntityNames + // if both are passed in and the manager's selection is not empty. + // this is necessary for the global entity selector to work correctly. + if (options.manager?.selection?.hasSelection) { + this.updateFromObject(omit(options, "selectedEntityNames")) + } else { + this.updateFromObject(options) } - if (this.isStatic) return 18 - return this._baseFontSize - } - @observable private _baseFontSize = BASE_FONT_SIZE - @computed get staticBounds(): Bounds { - if (this.props.staticBounds) return this.props.staticBounds - return this.getStaticBounds(this.staticFormat) - } - @observable.ref private _staticFormat = GrapherStaticFormat.landscape - @observable hideTitle = false - @observable hideSubtitle = false - @observable hideNote = false - @observable hideOriginUrl = false - - // For now I am only exposing this programmatically for the dashboard builder. Setting this to true - // allows you to still use add country "modes" without showing the buttons in order to prioritize - // another entity selector over the built in ones. - @observable hideEntityControls = false - - // exposed programmatically for hiding interactive controls or tabs when desired - // (e.g. used to hide Grapher chrome when a Grapher chart in a Gdoc article is in "read-only" mode) - @observable hideZoomToggle = false - @observable hideNoDataAreaToggle = false - @observable hideFacetYDomainToggle = false - @observable hideXScaleToggle = false - @observable hideYScaleToggle = false - @observable hideMapProjectionMenu = false - @observable hideTableFilterToggle = 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, - time: false, - changeInPrefix: false, - } - @observable hasTableTab = true - @observable hideChartTabs = false - @observable hideShareButton = false - @observable hideExploreTheDataButton = true - @observable hideRelatedQuestion = false + if (options.dataApiUrl) this.dataApiUrl = options.dataApiUrl - @observable.ref isSocialMediaExport = false - // getGrapherInstance defined in interface but not on Grapher (as a property - it is set in the constructor) - - enableKeyboardShortcuts?: boolean + if (options.staticFormat) this._staticFormat = options.staticFormat + this.staticBounds = + options.staticBounds ?? this.getStaticBounds(this._staticFormat) - bindUrlToWindow?: boolean - - isEmbeddedInAnOwidPage?: boolean = this.props.isEmbeddedInAnOwidPage - isEmbeddedInADataPage?: boolean = this.props.isEmbeddedInADataPage - - chartViewInfo?: Pick< - ChartViewInfo, - "parentChartSlug" | "queryParamsForParentChart" - > = undefined + this.externalQueryParams = omit( + Url.fromQueryStr(options.queryStr ?? "").queryParams, + GRAPHER_QUERY_PARAM_KEYS + ) + this.inputTable = options.table ?? BlankOwidTable(`initialGrapherTable`) + this.initialOptions = options + this.selection = + this.manager?.selection ?? + new SelectionArray( + this.initialOptions.selectedEntityNames ?? [], + this.initialOptions.table?.availableEntities ?? [] + ) + this.setAuthoredVersion(options) - @computed private get manager(): GrapherManager | undefined { - return this.props.manager + this.populateFromQueryParams( + legacyToCurrentGrapherQueryParams( + this.initialOptions.queryStr ?? "" + ) + ) + if (this.isEditor) { + this.ensureValidConfigWhenEditing() + } } - // instanceRef defined in interface but not on Grapher - // #endregion GrapherProgrammaticInterface properties + @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void { + if (!obj) return - // #region Start TimelineManager propertes + updatePersistables(this, obj) - @computed get disablePlay(): boolean { - return false - } + // Regression fix: some legacies have this set to Null. Todo: clean DB. + if (obj.originUrl === null) this.originUrl = "" - formatTimeFn(time: Time): string { - return this.inputTable.timeColumn.formatTime(time) - } + // update selection + if (obj.selectedEntityNames) + this.selection.setSelectedEntities(obj.selectedEntityNames) - @observable.ref isPlaying = false - @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished + // update focus + if (obj.focusedSeriesNames) + this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames) - @computed get times(): Time[] { - const columnSlugs = this.isOnMapTab - ? [this.mapColumnSlug] - : this.yColumnSlugs + // JSON doesn't support Infinity, so we use strings instead. + this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) + this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) - // Generate the times only after the chart transform has been applied, so that we don't show - // times on the timeline for which data may not exist, e.g. when the selected entity - // doesn't contain data for all years in the table. - // -@danielgavrilov, 2020-10-22 - return this.tableAfterAuthorTimelineAndActiveChartTransform.getTimesUniqSortedAscForColumns( - columnSlugs + this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity( + obj.timelineMinTime + ) + this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity( + obj.timelineMaxTime ) - } - @computed get startHandleTimeBound(): TimeBound { - if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound - return this.timelineHandleTimeBounds[0] - } - @computed get endHandleTimeBound(): TimeBound { - return this.timelineHandleTimeBounds[1] - } - - @observable.ref areHandlesOnSameTimeBeforeAnimation?: boolean - msPerTick = DEFAULT_MS_PER_TICK - // missing from TimelineManager: onPlay - @action.bound onTimelineClick(): void { - const tooltip = this.tooltip?.get() - if (tooltip) tooltip.dismiss?.() - } - // #endregion TimelineManager properties - - // #region ChartManager properties - base: React.RefObject = React.createRef() - - @computed get fontSize(): number { - return this.props.baseFontSize ?? this.baseFontSize - } - // table defined in interface but not on Grapher - @computed get transformedTable(): OwidTable { - return this.tableAfterAllTransformsAndFilters + // Todo: remove once we are more RAII. + if (obj?.dimensions?.length) + this.setDimensionsFromConfigs(obj.dimensions) } - @observable.ref isExportingToSvgOrPng = false + @action.bound populateFromQueryParams(params: GrapherQueryParams): void { + // Set tab if specified + if (params.tab) { + const tab = this.mapQueryParamToGrapherTab(params.tab) + if (tab) this.setTab(tab) + else console.error("Unexpected tab: " + params.tab) + } - // comparisonLines defined previously - @computed get showLegend(): boolean { - // hide the legend for stacked bar charts - // if the legend only ever shows a single entity - if (this.isOnStackedBarTab) { - const seriesStrategy = - this.chartInstance.seriesStrategy || - autoDetectSeriesStrategy(this, true) - const isEntityStrategy = seriesStrategy === SeriesStrategy.entity - const hasSingleEntity = this.selection.numSelectedEntities === 1 - const hideLegend = - this.hideLegend || (isEntityStrategy && hasSingleEntity) - return !hideLegend + // Set overlay if specified + const overlay = params.overlay + if (overlay) { + if (overlay === "sources") { + this.isSourcesModalOpen = true + } else if (overlay === "download") { + this.isDownloadModalOpen = true + } else { + console.error("Unexpected overlay: " + overlay) + } } - return !this.hideLegend - } - - tooltip?: TooltipManager["tooltip"] = observable.box(undefined, { - deep: false, - }) - // baseColorScheme defined previously - // invertColorScheme defined previously - // compareEndPointsOnly defined previously - // zoomToSelection defined previously - // matchingEntitiesOnly defined previously - // colorScale defined previously - // colorScaleColumnOverride defined in interface but not on Grapher - // colorScaleOverride defined in interface but not on Grapher - // useValueBasedColorScheme defined in interface but not on Grapher + // Stack mode for bar and stacked area charts + this.stackMode = (params.stackMode ?? this.stackMode) as StackMode - @computed get yAxisConfig(): Readonly { - return this.yAxis.toObject() - } + this.zoomToSelection = + params.zoomToSelection === "true" ? true : this.zoomToSelection - @computed get xAxisConfig(): Readonly { - return this.xAxis.toObject() - } + // Axis scale mode + const xScaleType = params.xScale + if (xScaleType) { + if (xScaleType === ScaleType.linear || xScaleType === ScaleType.log) + this.xAxis.scaleType = xScaleType + else console.error("Unexpected xScale: " + xScaleType) + } - @computed get yColumnSlugs(): string[] { - return this.ySlugs - ? this.ySlugs.split(" ") - : this.dimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.columnSlug) - } + const yScaleType = params.yScale + if (yScaleType) { + if (yScaleType === ScaleType.linear || yScaleType === ScaleType.log) + this.yAxis.scaleType = yScaleType + else console.error("Unexpected xScale: " + yScaleType) + } - @computed get yColumnSlug(): string | undefined { - return this.ySlugs - ? this.ySlugs.split(" ")[0] - : this.getSlugForProperty(DimensionProperty.y) - } + const time = params.time + if (time !== undefined && time !== "") + this.setTimeFromTimeQueryParam(time) - @computed get xColumnSlug(): string | undefined { - return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) - } + const endpointsOnly = params.endpointsOnly + if (endpointsOnly !== undefined) + this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined - @computed get sizeColumnSlug(): string | undefined { - return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) - } + const region = params.region + if (region !== undefined) + this.map.projection = region as MapProjectionName - @computed get colorColumnSlug(): string | undefined { - return ( - this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) + // selection + const selection = getSelectedEntityNamesParam( + Url.fromQueryParams(params) ) - } + if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) + this.selection.setSelectedEntities(selection) - selection = - this.manager?.selection ?? - new SelectionArray( - this.props.selectedEntityNames ?? [], - this.props.table?.availableEntities ?? [] - ) - // entityType defined previously - // focusArray defined previously - // hidePoints defined in interface but not on Grapher - // startHandleTimeBound defined previously - // hideNoDataSection defined in interface but not on Grapher - @computed get startTime(): Time | undefined { - return findClosestTime(this.times, this.startHandleTimeBound) - } + // focus + const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus) + if (focusedSeriesNames) { + this.focusArray.clearAllAndAdd(...focusedSeriesNames) + } - @computed get endTime(): Time | undefined { - return findClosestTime(this.times, this.endHandleTimeBound) - } - // facetStrategy defined previously - // seriesStrategy defined in interface but not on Grapher - @computed get _sortConfig(): Readonly { - return { - sortBy: this.sortBy ?? SortBy.total, - sortOrder: this.sortOrder ?? SortOrder.desc, - sortColumnSlug: this.sortColumnSlug, + // faceting + if (params.facet && params.facet in FacetStrategy) { + this.selectedFacetStrategy = params.facet as FacetStrategy + } + if (params.uniformYAxis === "0") { + this.yAxis.facetDomain = FacetAxisDomain.independent + } else if (params.uniformYAxis === "1") { + this.yAxis.facetDomain = FacetAxisDomain.shared } - } - @computed get sortConfig(): SortConfig { - const sortConfig = { ...this._sortConfig } - // In relative mode, where the values for every entity sum up to 100%, sorting by total - // doesn't make sense. It's also jumpy because of some rounding errors. For this reason, - // we sort by entity name instead. - // Marimekko charts are special and there we don't do this forcing of sort order - if ( - !this.isMarimekko && - this.isRelativeMode && - sortConfig.sortBy === SortBy.total - ) { - sortConfig.sortBy = SortBy.entityName - sortConfig.sortOrder = SortOrder.asc + // only relevant for the table + if (params.showSelectionOnlyInTable) { + this.showSelectionOnlyInDataTable = + params.showSelectionOnlyInTable === "1" ? true : undefined } - return sortConfig - } - // showNoDataArea defined previously - // externalLegendHoverBin defined in interface but not on Grapher - @computed get disableIntroAnimation(): boolean { - return this.isStatic - } - // missingDataStrategy defined previously - @computed get isNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 420 - } - @computed get isStatic(): boolean { - return this.renderToStatic || this.isExportingToSvgOrPng + if (params.showNoDataArea) { + this.showNoDataArea = params.showNoDataArea === "1" + } } - @computed get isSemiNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 550 - } + toObject(): GrapherInterface { + const obj: GrapherInterface = objectWithPersistablesToObject( + this, + grapherKeysToSerialize + ) - @computed get isStaticAndSmall(): boolean { - if (!this.isStatic) return false - return this.areStaticBoundsSmall - } - // isExportingForSocialMedia defined previously - @computed get backgroundColor(): Color { - return this.isExportingForSocialMedia - ? GRAPHER_BACKGROUND_BEIGE - : GRAPHER_BACKGROUND_DEFAULT - } + obj.selectedEntityNames = this.selection.selectedEntityNames + obj.focusedSeriesNames = this.focusArray.seriesNames - @computed get shouldPinTooltipToBottom(): boolean { - return this.isNarrow && this.isTouchDevice - } + deleteRuntimeAndUnchangedProps(obj, defaultObject) - // Used for superscript numbers in static exports - @computed get detailsOrderedByReference(): string[] { - if (typeof window === "undefined") return [] + // always include the schema, even if it's the default + obj.$schema = this.$schema || latestGrapherConfigSchema - // extract details from supporting text - const subtitleDetails = !this.hideSubtitle - ? extractDetailsFromSyntax(this.currentSubtitle) - : [] - const noteDetails = !this.hideNote - ? extractDetailsFromSyntax(this.note ?? "") - : [] + // JSON doesn't support Infinity, so we use strings instead. + if (obj.minTime) obj.minTime = minTimeToJSON(this.minTime) as any + if (obj.maxTime) obj.maxTime = maxTimeToJSON(this.maxTime) as any - // extract details from axis labels - const yAxisDetails = extractDetailsFromSyntax( - this.yAxisConfig.label || "" - ) - const xAxisDetails = extractDetailsFromSyntax( - this.xAxisConfig.label || "" - ) + if (obj.timelineMinTime) + obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any + if (obj.timelineMaxTime) + obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any - // text fragments are ordered by appearance - const uniqueDetails = uniq([ - ...subtitleDetails, - ...yAxisDetails, - ...xAxisDetails, - ...noteDetails, - ]) + // todo: remove dimensions concept + // if (this.legacyConfigAsAuthored?.dimensions) + // obj.dimensions = this.legacyConfigAsAuthored.dimensions - return uniqueDetails + return obj } - @computed get detailsMarkerInSvg(): DetailsMarker { - const { isStatic, shouldIncludeDetailsInStaticExport } = this - return !isStatic - ? "underline" - : shouldIncludeDetailsInStaticExport - ? "superscript" - : "none" + // todo: can we remove this? + // I believe these states can only occur during editing. + @action.bound private ensureValidConfigWhenEditing(): void { + const disposers = [ + autorun(() => { + if (!this.availableTabs.includes(this.activeTab)) + runInAction(() => this.setTab(this.availableTabs[0])) + }), + autorun(() => { + const validDimensions = this.validDimensions + if (!isEqual(this.dimensions, validDimensions)) + this.dimensions = validDimensions + }), + ] + this.disposers.push(...disposers) } - // #endregion ChartManager properties + disposers: (() => void)[] = [] + private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { + const { + chartType: defaultChartType, + validChartTypeSet, + hasMapTab, + } = this + + if (tab === GRAPHER_TAB_QUERY_PARAMS.table) { + return GRAPHER_TAB_NAMES.Table + } + if (tab === GRAPHER_TAB_QUERY_PARAMS.map) { + return GRAPHER_TAB_NAMES.WorldMap + } + + if (tab === GRAPHER_TAB_QUERY_PARAMS.chart) { + if (defaultChartType) { + return defaultChartType + } else if (hasMapTab) { + return GRAPHER_TAB_NAMES.WorldMap + } else { + return GRAPHER_TAB_NAMES.Table + } + } - // #region AxisManager - // fontSize defined previously - // detailsOrderedByReference defined previously - // #endregion + const chartTypeName = mapQueryParamToChartTypeName(tab) - // CaptionedChartManager interface ommited (only used for testing) + if (!chartTypeName) return undefined - // #region SourcesModalManager props + if (validChartTypeSet.has(chartTypeName)) { + return chartTypeName + } else if (defaultChartType) { + return defaultChartType + } else if (hasMapTab) { + return GRAPHER_TAB_NAMES.WorldMap + } else { + return GRAPHER_TAB_NAMES.Table + } + } - // Ready to go iff we have retrieved data for every variable associated with the chart - @computed get isReady(): boolean { - return this.whatAreWeWaitingFor === "" + @action.bound setTimeFromTimeQueryParam(time: string): void { + this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( + (time) => findClosestTime(this.times, time) ?? time + ) as TimeBounds } - // adminBaseUrl defined previously - @computed get columnsWithSourcesExtensive(): CoreColumn[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this - // sort y-columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + @computed private get validDimensions(): ChartDimension[] { + const { dimensions } = this + const validProperties = this.dimensionSlots.map((d) => d.property) + let validDimensions = dimensions.filter((dim) => + validProperties.includes(dim.property) ) - const columnSlugs = excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) + this.dimensionSlots.forEach((slot) => { + if (!slot.allowMultiple) + validDimensions = uniqWith( + validDimensions, + ( + a: OwidChartDimensionInterface, + b: OwidChartDimensionInterface + ) => + a.property === slot.property && + a.property === b.property + ) + }) - return this.inputTable - .getColumns(uniq(columnSlugs)) - .filter( - (column) => !!column.source.name || !isEmpty(column.def.origins) - ) + return validDimensions } - @computed get showAdminControls(): boolean { - return ( - this.isUserLoggedInAsAdmin || - this.isDev || - this.isLocalhost || - this.isStaging - ) - } - // isSourcesModalOpen defined previously + // Get the dimension slots appropriate for this type of chart + @computed get dimensionSlots(): DimensionSlot[] { + const xAxis = new DimensionSlot(this, DimensionProperty.x) + const yAxis = new DimensionSlot(this, DimensionProperty.y) + const color = new DimensionSlot(this, DimensionProperty.color) + const size = new DimensionSlot(this, DimensionProperty.size) - @computed get frameBounds(): Bounds { - return this.useIdealBounds - ? new Bounds(0, 0, this.idealWidth, this.idealHeight) - : new Bounds(0, 0, this.availableWidth, this.availableHeight) + if (this.isLineChart || this.isDiscreteBar) return [yAxis, color] + else if (this.isScatter) return [yAxis, xAxis, size, color] + else if (this.isMarimekko) return [yAxis, xAxis, color] + return [yAxis] } - // isEmbeddedInAnOwidPage defined previously - // isNarrow defined previously - // fontSize defined previously - // #endregion + /** + * todo: factor this out and make more RAII. + * + * Explorers create 1 Grapher instance, but as the user clicks around the Explorer loads other author created Graphers. + * But currently some Grapher features depend on knowing how the current state is different than the "authored state". + * So when an Explorer updates the grapher, it also needs to update this "original state". + */ + @action.bound setAuthoredVersion( + config: Partial + ): void { + this.legacyConfigAsAuthored = config + } - // #region DownloadModalManager - @computed get displaySlug(): string { - return this.slug ?? slugify(this.displayTitle) + initialOptions: GrapherProgrammaticInterface + @action.bound setDimensionsFromConfigs( + configs: OwidChartDimensionInterface[] + ): void { + this.dimensions = configs.map( + (config) => new ChartDimension(config, this) + ) } - rasterize(): Promise { - const { width, height } = this.staticBoundsWithDetails - const staticSVG = this.generateStaticSvg() + // #region SortConfig props + @observable sortBy?: SortBy = SortBy.total + @observable sortOrder?: SortOrder = SortOrder.desc + @observable sortColumnSlug?: string + // #endregion - return new StaticChartRasterizer(staticSVG, width, height).render() - } - // staticBounds defined previously + // #region GrapherInterface props + @observable.ref $schema = latestGrapherConfigSchema + @observable.ref chartTypes: GrapherChartType[] = [ + GRAPHER_CHART_TYPES.LineChart, + ] + @observable.ref id?: number = undefined + @observable.ref version = 1 + @observable.ref slug?: string = undefined - @computed get staticBoundsWithDetails(): Bounds { - const includeDetails = - this.shouldIncludeDetailsInStaticExport && - !isEmpty(this.detailRenderers) + // Initializing text fields with `undefined` ensures that empty strings get serialised + @observable.ref title?: string = undefined + @observable.ref subtitle: string | undefined = undefined + @observable.ref sourceDesc?: string = undefined + @observable.ref note?: string = undefined + @observable hideAnnotationFieldsInTitle?: AnnotationFieldsInTitle = + undefined - let height = this.staticBounds.height - if (includeDetails) { - height += - 2 * this.framePaddingVertical + - sumTextWrapHeights( - this.detailRenderers, - STATIC_EXPORT_DETAIL_SPACING - ) - } + @observable.ref minTime?: TimeBound = undefined + @observable.ref maxTime?: TimeBound = undefined + @observable.ref timelineMinTime?: Time = undefined + @observable.ref timelineMaxTime?: Time = undefined + @observable.ref dimensions: ChartDimension[] = [] + @observable.ref addCountryMode = EntitySelectionMode.MultipleEntities + @observable comparisonLines?: ComparisonLineConfig[] = undefined // todo: Persistables? + @observable.ref stackMode = StackMode.absolute + @observable.ref showNoDataArea = true + @observable.ref hideLegend?: boolean = false + @observable.ref logo?: LogoOption = undefined + @observable.ref hideLogo?: boolean = undefined + @observable.ref hideRelativeToggle? = true + @observable.ref entityType = DEFAULT_GRAPHER_ENTITY_TYPE + @observable.ref entityTypePlural = DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL + @observable.ref hideTimeline?: boolean = undefined + @observable.ref zoomToSelection?: boolean = undefined + @observable.ref showYearLabels?: boolean = undefined // Always show year in labels for bar charts + @observable.ref hasMapTab = false + @observable.ref tab: GrapherTabOption = GRAPHER_TAB_OPTIONS.chart + @observable relatedQuestions?: RelatedQuestionsConfig[] = undefined // todo: Persistables? + // Missing from GrapherInterface: details + @observable.ref internalNotes?: string = undefined + @observable.ref variantName?: string = undefined + @observable.ref originUrl?: string = undefined + @observable.ref isPublished?: boolean = undefined + @observable.ref baseColorScheme?: ColorSchemeName = undefined + @observable.ref invertColorScheme?: boolean = undefined + @observable hideConnectedScatterLines?: boolean = undefined // Hides lines between points when timeline spans multiple years. Requested by core-econ for certain charts + @observable.ref hideScatterLabels?: boolean = undefined + @observable + scatterPointLabelStrategy?: ScatterPointLabelStrategy = undefined + @observable.ref compareEndPointsOnly?: boolean = undefined + @observable.ref matchingEntitiesOnly?: boolean = undefined + /** Hides the total value label that is normally displayed for stacked bar charts */ + @observable.ref hideTotalValueLabel?: boolean = undefined + @observable excludedEntities?: number[] = undefined + /** IncludedEntities are usually empty which means use all available entities. When + includedEntities is set it means "only use these entities". excludedEntities + are evaluated afterwards and can still remove entities even if they were included before. + */ + @observable includedEntities?: number[] = undefined + @observable selectedEntityNames: EntityName[] = [] + @observable selectedEntityColors: { + [entityName: string]: string | undefined + } = {} - return new Bounds(0, 0, this.staticBounds.width, height) - } + @observable focusedSeriesNames: SeriesName[] = [] + @observable.ref missingDataStrategy?: MissingDataStrategy = undefined + @observable.ref hideFacetControl?: boolean = undefined + @observable.ref facettingLabelByYVariables = "metric" + // the desired faceting strategy, which might not be possible if we change the data + @observable selectedFacetStrategy?: FacetStrategy = undefined - @computed get staticFormat(): GrapherStaticFormat { - if (this.props.staticFormat) return this.props.staticFormat - return this._staticFormat - } + @observable.ref xAxis = new AxisConfig(undefined, this) + @observable.ref yAxis = new AxisConfig(undefined, this) + @observable colorScale = new ColorScaleConfig() + @observable map = new MapConfig() - @computed get baseUrl(): string | undefined { - return this.isPublished - ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` - : undefined - } - // queryStr defined previously - // table defined previously - // transformedTable defined previously + @observable ySlugs?: ColumnSlugs = undefined + @observable xSlug?: ColumnSlug = undefined + @observable sizeSlug?: ColumnSlug = undefined + @observable colorSlug?: ColumnSlug = undefined + @observable tableSlugs?: ColumnSlugs = undefined - // todo: remove when we remove dimensions - @computed get yColumnsFromDimensionsOrSlugsOrAuto(): CoreColumn[] { - return this.yColumnsFromDimensions.length - ? this.yColumnsFromDimensions - : this.table.getColumns(autoDetectYColumnSlugs(this)) - } - // shouldIncludeDetailsInStaticExport defined previously - // detailsOrderedByReference defined previously - // isDownloadModalOpen defined previously - // frameBounds defined previously + // #endregion GrapherInterface properties - @computed get captionedChartBounds(): Bounds { - // if there's no panel, the chart takes up the whole frame - if (!this.isEntitySelectorPanelActive) return this.frameBounds + // #region GrapherProgrammaticInterface props - return new Bounds( - 0, - 0, - // the chart takes up 9 columns in 12-column grid - (9 / 12) * this.frameBounds.width, - this.frameBounds.height - 2 // 2px accounts for the border - ) + owidDataset?: MultipleOwidVariableDataDimensionsMap = undefined // This is used for passing data for testing + manuallyProvideData? = false // This will be removed. + @computed get queryStr(): string { + return queryParamsToStr({ + ...this.changedParams, + ...this.externalQueryParams, + }) + } + // bounds defined in interface but not on Grapher + @computed get table(): OwidTable { + return this.tableAfterAuthorTimelineFilter } - @computed get isOnChartOrMapTab(): boolean { - return this.isOnChartTab || this.isOnMapTab + @observable bakedGrapherURL: string | undefined = undefined + adminBaseUrl: string | undefined = undefined + dataApiUrl: string = "https://api.ourworldindata.org/v1/indicators/" + // env defined in interface but not on Grapher + dataApiUrlForAdmin: string | undefined = undefined + /** + * Used to highlight an entity at a particular time in a line chart. + * The sparkline in map tooltips makes use of this. + */ + @observable.ref entityYearHighlight?: EntityYearHighlight = undefined + + @computed get baseFontSize(): number { + if (this.isStaticAndSmall) { + return this.computeBaseFontSizeFromHeight(this.staticBounds) + } + if (this.isStatic) return 18 + return this._baseFontSize } - // showAdminControls defined previously - // isSocialMediaExport defined previously - // isPublished defined previously - // Columns that are used as a dimension in the currently active view - @computed get activeColumnSlugs(): string[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this + @observable _baseFontSize = BASE_FONT_SIZE + @observable staticBounds: Bounds + @observable.ref _staticFormat = GrapherStaticFormat.landscape + @observable hideTitle = false + @observable hideSubtitle = false + @observable hideNote = false + @observable hideOriginUrl = false - // sort y columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title - ) + // For now I am only exposing this programmatically for the dashboard builder. Setting this to true + // allows you to still use add country "modes" without showing the buttons in order to prioritize + // another entity selector over the built in ones. + @observable hideEntityControls = false - return excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) + // exposed programmatically for hiding interactive controls or tabs when desired + // (e.g. used to hide Grapher chrome when a Grapher chart in a Gdoc article is in "read-only" mode) + @observable hideZoomToggle = false + @observable hideNoDataAreaToggle = false + @observable hideFacetYDomainToggle = false + @observable hideXScaleToggle = false + @observable hideYScaleToggle = false + @observable hideMapProjectionMenu = false + @observable hideTableFilterToggle = 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, + time: false, + changeInPrefix: false, } + @observable hasTableTab = true + @observable hideChartTabs = false + @observable hideShareButton = false + @observable hideExploreTheDataButton = true + @observable hideRelatedQuestion = false - // #endregion + @observable.ref isSocialMediaExport = false + // getGrapherInstance defined in interface but not on Grapher (as a property - it is set in the constructor) - // #region DiscreteBarChartManager props + enableKeyboardShortcuts?: boolean - // showYearLabels defined previously - // endTime defined previously + bindUrlToWindow?: boolean - @computed get isOnLineChartTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.LineChart - } - // #endregion + isEmbeddedInAnOwidPage?: boolean = false + isEmbeddedInADataPage?: boolean = false - // LegacyDimensionsManager omitted (only defines table) + chartViewInfo?: Pick< + ChartViewInfo, + "parentChartSlug" | "queryParamsForParentChart" + > = undefined - // #region ShareMenuManager props - // slug defined previously + readonly manager: GrapherManager | undefined = undefined + // instanceRef defined in interface but not on Grapher - @computed get currentTitle(): string { - let text = this.displayTitle.trim() - if (text.length === 0) return text + // 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) + } - // helper function to add an annotation fragment to the title - // only adds a comma if the text does not end with a question mark - const appendAnnotationField = ( - text: string, - annotation: string - ): string => { - const separator = text.endsWith("?") ? "" : "," - return `${text}${separator} ${annotation}` - } + // computed properties that are needed by the above - if (this.shouldAddEntitySuffixToTitle) { - const selectedEntityNames = this.selection.selectedEntityNames - const entityStr = selectedEntityNames[0] - if (entityStr?.length) text = appendAnnotationField(text, entityStr) - } + @observable.ref externalQueryParams: QueryParams + @computed.struct get allParams(): GrapherQueryParams { + return grapherObjectToQueryParams(this) + } + @computed get tableForSelection(): OwidTable { + // This table specifies which entities can be selected in the charts EntitySelectorModal. + // It should contain all entities that can be selected, and none more. + // Depending on the chart type, the criteria for being able to select an entity are + // different; e.g. for scatterplots, the entity needs to (1) not be excluded and + // (2) needs to have data for the x and y dimension. + let table = this.isScatter + ? this.tableAfterAuthorTimelineAndActiveChartTransform + : this.inputTable - if (this.shouldAddChangeInPrefixToTitle) - text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) + if (!this.isReady) return table - if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) - text = appendAnnotationField(text, this.timeTitleSuffix) + // Some chart types (e.g. stacked area charts) choose not to show an entity + // with incomplete data. Such chart types define a custom transform function + // to ensure that the entity selector only offers entities that are actually plotted. + if (this.chartInstance.transformTableForSelection) { + table = this.chartInstance.transformTableForSelection(table) + } - return text.trim() + return table } - // Get the full url representing the canonical location of this grapher state - @computed get canonicalUrl(): string | undefined { - return ( - this.manager?.canonicalUrl ?? - this.canonicalUrlIfIsChartView ?? - (this.baseUrl ? this.baseUrl + this.queryStr : undefined) - ) - } + /** + * Input table with color and size tolerance applied. + * + * This happens _before_ applying the author's timeline filter to avoid + * accidentally dropping all color values before applying tolerance. + * This is especially important for scatter plots and Marimekko charts, + * where color and size columns are often transformed with infinite tolerance. + * + * Line and discrete bar charts also support a color dimension, but their + * tolerance transformations run in their respective transformTable functions + * since it's more efficient to run them on a table that has been filtered + * by selected entities. + */ + @computed get tableAfterColorAndSizeToleranceApplication(): OwidTable { + let table = this.inputTable - @computed get editUrl(): string | undefined { - if (this.showAdminControls) { - return `${this.adminBaseUrl}/admin/${ - this.manager?.editUrl ?? `charts/${this.id}/edit` - }` + if (this.isScatter && this.sizeColumnSlug) { + const tolerance = + table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.sizeColumnSlug, + tolerance + ) } - return undefined - } - // isEmbedModalOpen defined previously - // #endregion - // #region EmbedModalManager props - // canonicalUrl defined previously - @computed get embedUrl(): string | undefined { - const url = this.manager?.embedDialogUrl ?? this.canonicalUrl - if (!url) return - - // We want to preserve the tab in the embed URL so that if we change the - // default view of the chart, it won't change existing embeds. - // 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 }) + if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { + const tolerance = + table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.colorColumnSlug, + tolerance + ) } - return urlObj.fullUrl - } - @computed get embedDialogAdditionalElements(): - | React.ReactElement - | undefined { - return this.manager?.embedDialogAdditionalElements + return table } - // isEmbedModalOpen defined previously - // frameBounds defined previously - // #endregion - - // TooltipManager omitted (only defines tooltip) - // #region DataTableManager props - // table defined previously - // table that is used for display in the table tab - @computed get tableForDisplay(): OwidTable { - const table = this.table - if (!this.isReady || !this.isOnTableTab) return table - return this.chartInstance.transformTableForDisplay - ? this.chartInstance.transformTableForDisplay(table) - : table - } - // entityType defined previously - // endTime defined previously - // startTime defined previously + // If an author sets a timeline filter run it early in the pipeline so to the charts it's as if the filtered times do not exist + @computed get tableAfterAuthorTimelineFilter(): OwidTable { + const table = this.tableAfterColorAndSizeToleranceApplication - @computed get dataTableSlugs(): ColumnSlug[] { - return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs + if ( + this.timelineMinTime === undefined && + this.timelineMaxTime === undefined + ) + return table + return table.filterByTimeRange( + this.timelineMinTime ?? -Infinity, + this.timelineMaxTime ?? Infinity + ) } - @observable.ref showSelectionOnlyInDataTable?: boolean = undefined + @computed + get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable { + const table = this.tableAfterAuthorTimelineFilter + if (!this.isReady || !this.isOnChartOrMapTab) return table - @computed get entitiesAreCountryLike(): boolean { - return !!this.entityType.match(/\bcountry\b/i) - } - // Small charts are rendered into 6 or 7 columns in a 12-column grid layout - // (e.g. side-by-side charts or charts in the All Charts block) - @computed get isSmall(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 740 - } + const startMark = performance.now() - // Medium charts are rendered into 8 columns in a 12-column grid layout - // (e.g. stand-alone charts in the main text of an article) - @computed get isMedium(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 845 - } - // isNarrow defined previoulsy - // selection defined previously + const transformedTable = this.chartInstance.transformTable(table) - @computed get canChangeAddOrHighlightEntities(): boolean { - return ( - this.canChangeEntity || - this.canAddEntities || - this.canHighlightEntities + this.createPerformanceMeasurement( + "chartInstance.transformTable", + startMark ) + return transformedTable } - // hasMapTab defined previously - @computed get hasChartTab(): boolean { - return this.validChartTypes.length > 0 - } - // #endregion DataTableManager props - // #region ScatterPlotManager props - // hideConnectedScatterLines defined previously - // scatterPointLabelStrategy defined previously - // addCountryMode defined previously + @computed + private get tableAfterAllTransformsAndFilters(): OwidTable { + const { startTime, endTime } = this + const table = this.tableAfterAuthorTimelineAndActiveChartTransform - // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? - // todo: remove this. Should be done as a simple column transform at the data level. - // Possible to override the x axis dimension to target a special year - // In case you want to graph say, education in the past and democracy today https://ourworldindata.org/grapher/correlation-between-education-and-democracy - @computed get xOverrideTime(): number | undefined { - return this.xDimension?.targetYear - } - // tableAfterAuthorTimelineAndActiveChartTransform defined below (together with other table transforms) + if (startTime === undefined || endTime === undefined) return table - /** - * Uses some explicit and implicit information to decide whether a timeline is shown. - */ - @computed get hasTimeline(): boolean { - // we don't have more than one distinct time point in our data, so it doesn't make sense to show a timeline - if (this.times.length <= 1) return false + if (this.isOnMapTab) + return table.filterByTargetTimes( + [endTime], + this.map.timeTolerance ?? + table.get(this.mapColumnSlug).tolerance + ) - switch (this.tab) { - // the map tab has its own `hideTimeline` option - case GRAPHER_TAB_OPTIONS.map: - return !this.map.hideTimeline + if ( + this.isDiscreteBar || + this.isLineChartThatTurnedIntoDiscreteBar || + this.isMarimekko + ) + return table.filterByTargetTimes( + [endTime], + table.get(this.yColumnSlugs[0]).tolerance + ) - // use the chart-level `hideTimeline` option - case GRAPHER_TAB_OPTIONS.chart: - return !this.hideTimeline + if (this.isOnSlopeChartTab) + return table.filterByTargetTimes( + [startTime, endTime], + table.get(this.yColumnSlugs[0]).tolerance + ) - // use the chart-level `hideTimeline` option for the table, with some exceptions - case GRAPHER_TAB_OPTIONS.table: - // always show the timeline for charts that plot time on the x-axis - if (this.hasTimeDimension) return true - return !this.hideTimeline + return table.filterByTimeRange(startTime, endTime) + } + + private computeBaseFontSizeFromHeight(bounds: Bounds): number { + const squareBounds = this.getStaticBounds(GrapherStaticFormat.square) + const factor = squareBounds.height / 21 + return Math.max(10, bounds.height / factor) + } + + computeBaseFontSizeFromWidth(bounds: Bounds): number { + if (bounds.width <= 400) return 14 + else if (bounds.width < 1080) return 16 + else if (bounds.width >= 1080) return 18 + else return 16 + } + getStaticBounds(format: GrapherStaticFormat): Bounds { + switch (format) { + case GrapherStaticFormat.landscape: + return this.defaultBounds + case GrapherStaticFormat.square: + return new Bounds( + 0, + 0, + GRAPHER_SQUARE_SIZE, + GRAPHER_SQUARE_SIZE + ) default: - return false + return this.defaultBounds } } - @computed get isModalOpen(): boolean { - return ( - this.isEntitySelectorModalOpen || - this.isSourcesModalOpen || - this.isEmbedModalOpen || - this.isDownloadModalOpen - ) + // If you want to compare current state against the published grapher. + @computed get authorsVersion(): GrapherState { + return new GrapherState({ + ...this.legacyConfigAsAuthored, + manager: undefined, + // TODO 2025-01-01: unclear if we can skip this below + // manuallyProvideData: true, + queryStr: "", + }) } - @computed get isSingleTimeScatterAnimationActive(): boolean { + @observable.ref legacyConfigAsAuthored: Partial = {} + @computed get isScatter(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.ScatterPlot + } + @computed get isStackedArea(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.StackedArea + } + @computed get isSlopeChart(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.SlopeChart + } + @computed get isDiscreteBar(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.DiscreteBar + } + @computed get isStackedBar(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.StackedBar + } + @computed get isMarimekko(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.Marimekko + } + @computed get isStackedDiscreteBar(): boolean { + return this.chartType === GRAPHER_CHART_TYPES.StackedDiscreteBar + } + + @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { return ( - this.isTimelineAnimationActive && - this.isOnScatterTab && - !this.isRelativeMode && - !!this.areHandlesOnSameTimeBeforeAnimation + this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar ) } - @observable.ref animationStartTime?: Time - @computed get animationEndTime(): Time { - const { timeColumn } = this.tableAfterAuthorTimelineFilter - if (this.timelineMaxTime) { - return ( - findClosestTime(timeColumn.uniqValues, this.timelineMaxTime) ?? - timeColumn.maxTime - ) - } - return timeColumn.maxTime + @computed get isOnScatterTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.ScatterPlot + } + @computed get isOnStackedAreaTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.StackedArea + } + @computed get isOnSlopeChartTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.SlopeChart + } + @computed get isOnDiscreteBarTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.DiscreteBar + } + @computed get isOnStackedBarTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.StackedBar + } + @computed get isOnMarimekkoTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.Marimekko + } + @computed get isOnStackedDiscreteBarTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.StackedDiscreteBar } - // #endregion ScatterPlotManager props - // #region MarimekkoChartManager props - // endTime defined previously - // excludedEntities defined previously - // matchingEntitiesOnly defined previously - // xOverrideTime defined previously - // tableAfterAuthorTimelineAndActiveChartTransform defined below (together with other table transforms) - // sortConfig defined previously - // hideNoDataArea defined previously - // includedEntities defined previously - // #endregion + @computed get hasLineChart(): boolean { + return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.LineChart) + } + @computed get hasSlopeChart(): boolean { + return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.SlopeChart) + } - // #region FacetChartManager + @computed get supportsMultipleYColumns(): boolean { + return !this.isScatter + } - @computed get canSelectMultipleEntities(): boolean { - if (this.numSelectableEntityNames < 2) return false - if (this.addCountryMode === EntitySelectionMode.MultipleEntities) - return true + @computed get activeTab(): GrapherTabName { + if (this.tab === GRAPHER_TAB_OPTIONS.table) + return GRAPHER_TAB_NAMES.Table + if (this.tab === GRAPHER_TAB_OPTIONS.map) + return GRAPHER_TAB_NAMES.WorldMap + if (this.chartTab) return this.chartTab + return this.chartType ?? GRAPHER_TAB_NAMES.LineChart + } - if ( - // we force multi-entity selection mode when the chart is faceted - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.facetStrategy !== FacetStrategy.none && - // unless the author explicitly configured the chart to be split - // by metric and hid the facet control - !( - this.facetStrategy === FacetStrategy.metric && - this.hideFacetControl - ) - ) - return true + @computed get chartType(): GrapherChartType | undefined { + return this.validChartTypes[0] + } + @observable.ref chartTab?: GrapherChartType + @observable.ref inputTable: OwidTable + @computed get chartInstance(): ChartInterface { + // Note: when timeline handles on a LineChart are collapsed into a single handle, the + // LineChart turns into a DiscreteBar. - return false + return this.isOnMapTab + ? new MapChart({ manager: this }) + : this.chartInstanceExceptMap } - // #endregion + createPerformanceMeasurement(name: string, startMark: number): void { + const endMark = performance.now() + const detail = { + devtools: { + track: "Grapher", + properties: [ + // might be missing for charts within explorers or mdims + ["slug", this.slug ?? "missing-slug"], + ["chartTypes", this.validChartTypes], + ["tab", this.tab], + ], + }, + } - // #region EntitySelectorModalManager + try { + performance.measure(name, { + start: startMark, + end: endMark, + detail, + }) + } catch { + // In old browsers, the above may throw an error - just ignore it + } + } + @computed get defaultBounds(): Bounds { + return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT) + } - @observable entitySelectorState: Partial = {} - // tableForSeleciton defined below (together with other table transforms) - // selection defined previously - // entityType defined previously - // entityTypePlural defined previously - // activeColumnSlugs defined previously - // dataApiUrl defined previously + // When Map becomes a first-class chart instance, we should drop this + @computed get chartInstanceExceptMap(): ChartInterface { + const chartTypeName = + this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart - @observable.ref isEntitySelectorModalOrDrawerOpen = false + const ChartClass = + ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass + return new ChartClass({ manager: this }) + } - @computed get canChangeEntity(): boolean { + @computed + get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { + return this.isLineChartThatTurnedIntoDiscreteBarActive + ? GRAPHER_CHART_TYPES.DiscreteBar + : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) + } + + @computed get isLineChart(): boolean { return ( - this.hasChartTab && - !this.isOnScatterTab && - !this.canSelectMultipleEntities && - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.numSelectableEntityNames > 1 + this.chartType === GRAPHER_CHART_TYPES.LineChart || !this.chartType ) } - @computed get canHighlightEntities(): boolean { + @computed private get isSingleTimeSelectionActive(): boolean { return ( - this.hasChartTab && - this.addCountryMode !== EntitySelectionMode.Disabled && - this.numSelectableEntityNames > 1 && - !this.canAddEntities && - !this.canChangeEntity + this.onlySingleTimeSelectionPossible || + this.isSingleTimeScatterAnimationActive ) } - focusArray = new FocusArray() + @computed private get onlySingleTimeSelectionPossible(): boolean { + return ( + this.isDiscreteBar || + this.isStackedDiscreteBar || + this.isOnMapTab || + this.isMarimekko + ) + } - // frameBounds defined previously - // #endregion + // #endregion GrapherProgrammaticInterface properties - // #region SettingsMenuManager + // #region Start TimelineManager propertes - // stackMode defined previously + @computed get disablePlay(): boolean { + return false + } - @computed get relativeToggleLabel(): string { - if (this.isOnScatterTab) return "Display average annual change" - else if (this.isOnLineChartTab || this.isOnSlopeChartTab) - return "Display relative change" - return "Display relative values" + formatTimeFn(time: Time): string { + return this.inputTable.timeColumn.formatTime(time) } - // showNoDataArea defined previously + @observable.ref isPlaying = false + @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished - // facetStrategy defined previously - // yAxis defined previously - // zoomToSelection defined previously - // showSelectedEntitiesOnly defined previously - // entityTypePlural defined previously + @computed get times(): Time[] { + const columnSlugs = this.isOnMapTab + ? [this.mapColumnSlug] + : this.yColumnSlugs - @computed get availableFacetStrategies(): FacetStrategy[] { - return this.chartInstance.availableFacetStrategies?.length - ? this.chartInstance.availableFacetStrategies - : [FacetStrategy.none] + // Generate the times only after the chart transform has been applied, so that we don't show + // times on the timeline for which data may not exist, e.g. when the selected entity + // doesn't contain data for all years in the table. + // -@danielgavrilov, 2020-10-22 + return this.tableAfterAuthorTimelineAndActiveChartTransform.getTimesUniqSortedAscForColumns( + columnSlugs + ) + } + @computed get startHandleTimeBound(): TimeBound { + if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound + return this.timelineHandleTimeBounds[0] + } + @computed get endHandleTimeBound(): TimeBound { + return this.timelineHandleTimeBounds[1] } - // entityType defined previously - // facettingLabelByYVariables defined previously - // hideFacetControl defined previously - // hideRelativeToggle defined previously - // hideEntityControls defined previously - // hideZoomToggle defined previously - // hideNoDataAreaToggle defined previously - // hideFacetYDomainToggle defined previously - // hideXScaleToggle defined previously - // hideYScaleToggle defined previously - // hideTableFilterToggle defined previously - - @computed get activeChartType(): GrapherChartType | undefined { - if (!this.isOnChartTab) return undefined - return this.activeTab as GrapherChartType + @observable.ref areHandlesOnSameTimeBeforeAnimation?: boolean + msPerTick = DEFAULT_MS_PER_TICK + // missing from TimelineManager: onPlay + @action.bound onTimelineClick(): void { + const tooltip = this.tooltip?.get() + if (tooltip) tooltip.dismiss?.() } - // NB: The timeline scatterplot in relative mode calculates changes relative - // to the lower bound year rather than creating an arrow chart - @computed get isRelativeMode(): boolean { - // don't allow relative mode in some cases - if ( - this.hasSingleMetricInFacets || - this.hasSingleEntityInFacets || - this.isStackedChartSplitByMetric - ) - return false - return this.stackMode === StackMode.relative + // required properties + + @computed get timelineHandleTimeBounds(): TimeBounds { + if (this.isOnMapTab) { + const time = maxTimeBoundFromJSONOrPositiveInfinity(this.map.time) + return [time, time] + } + + // If the timeline is hidden on the chart tab but displayed on the table tab + // (which is the case for charts that plot time on the x-axis), + // we always want to use the authored `minTime` and `maxTime` for the chart, + // irrespective of the time range the user might have selected on the table tab + if (this.isOnChartTab && this.hasTimeDimensionButTimelineIsHidden) { + const { minTime, maxTime } = this.authorsVersion + return [ + minTimeBoundFromJSONOrNegativeInfinity(minTime), + maxTimeBoundFromJSONOrPositiveInfinity(maxTime), + ] + } + + return [ + // Handle `undefined` values in minTime/maxTime + minTimeBoundFromJSONOrNegativeInfinity(this.minTime), + maxTimeBoundFromJSONOrPositiveInfinity(this.maxTime), + ] } - // selection defined previously - // canChangeAddOrHighlightEntities defined previously + set timelineHandleTimeBounds(value: TimeBounds) { + if (this.isOnMapTab) { + this.map.time = value[1] + } else { + this.minTime = value[0] + this.maxTime = value[1] + } + } - @computed.struct get filledDimensions(): ChartDimension[] { - return this.isReady ? this.dimensions : [] + @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { + return this.hasTimeDimension && !!this.hideTimeline } - // xColumnSlug defined previously - // xOverrideTime defined previously - // hasTimeline defined previously - @computed get canToggleRelativeMode(): boolean { - const { - isOnLineChartTab, - isOnSlopeChartTab, - hideRelativeToggle, - areHandlesOnSameTime, - yScaleType, - hasSingleEntityInFacets, - hasSingleMetricInFacets, - xColumnSlug, - isOnMarimekkoTab, - isStackedChartSplitByMetric, - } = this + /** + * Plots time on the x-axis. + */ + @computed private get hasTimeDimension(): boolean { + return this.isStackedBar || this.isStackedArea || this.isLineChart + } - if (isOnLineChartTab || isOnSlopeChartTab) - return ( - !hideRelativeToggle && - !areHandlesOnSameTime && - yScaleType !== ScaleType.log - ) + // #endregion TimelineManager properties - // actually trying to exclude relative mode with just one metric or entity - if ( - hasSingleEntityInFacets || - hasSingleMetricInFacets || - isStackedChartSplitByMetric - ) - return false + // #region ChartManager properties + base: React.RefObject = React.createRef() - if (isOnMarimekkoTab && xColumnSlug === undefined) return false - return !hideRelativeToggle + @computed get fontSize(): number { + return this.baseFontSize } + // table defined in interface but not on Grapher - @computed get isOnChartTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.chart + @computed get transformedTable(): OwidTable { + return this.tableAfterAllTransformsAndFilters } - @computed get isOnMapTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.map - } + @observable.ref isExportingToSvgOrPng = false - @computed get isOnTableTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.table + // comparisonLines defined previously + @computed get showLegend(): boolean { + // hide the legend for stacked bar charts + // if the legend only ever shows a single entity + if (this.isOnStackedBarTab) { + const seriesStrategy = + this.chartInstance.seriesStrategy || + autoDetectSeriesStrategy(this, true) + const isEntityStrategy = seriesStrategy === SeriesStrategy.entity + const hasSingleEntity = this.selection.numSelectedEntities === 1 + const hideLegend = + this.hideLegend || (isEntityStrategy && hasSingleEntity) + return !hideLegend + } + + return !this.hideLegend } - // yAxis defined previously - // xAxis defined previously + tooltip?: TooltipManager["tooltip"] = observable.box(undefined, { + deep: false, + }) + // baseColorScheme defined previously + // invertColorScheme defined previously // compareEndPointsOnly defined previously + // zoomToSelection defined previously + // matchingEntitiesOnly defined previously + // colorScale defined previously + // colorScaleColumnOverride defined in interface but not on Grapher + // colorScaleOverride defined in interface but not on Grapher + // useValueBasedColorScheme defined in interface but not on Grapher - // availableFacetStrategies defined previously - // the actual facet setting used by a chart, potentially overriding selectedFacetStrategy - @computed get facetStrategy(): FacetStrategy { - if ( - this.selectedFacetStrategy && - this.availableFacetStrategies.includes(this.selectedFacetStrategy) - ) - return this.selectedFacetStrategy - - if ( - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.selection.selectedEntityNames.length > 1 - ) { - return FacetStrategy.entity - } + @computed get yAxisConfig(): Readonly { + return this.yAxis.toObject() + } - return firstOfNonEmptyArray(this.availableFacetStrategies) + @computed get xAxisConfig(): Readonly { + return this.xAxis.toObject() } - // entityType defined previously - // facettingLabelByYVariables defined previously - // hideFacetControl defined previously - // hideRelativeToggle defined previously - // hideEntityControls defined previously - // hideZoomToggle defined previously - // hideNoDataAreaToggle defined previously - // hideFacetYDomainToggle defined previously - // hideXScaleToggle defined previously - // hideYScaleToggle defined previously - // hideTableFilterToggle defined previously - // activeChartType defined previously - // isRelativeMode defined previously - // selection defined previously - // canChangeAddOrHighlightEntities defined previously - // filledDimensions defined previously - // xColumnSlug defined previously - // xOverrideTime defined previously - // hasTimeline defined previously - // canToggleRelativeMode defined previously - // isOnChartTab defined previously - // isOnMapTab defined previously - // isOnTableTab defined previously - // yAxis defined previously - // xAxis defined previously - // compareEndPointsOnly defined previously + @computed get yColumnSlugs(): string[] { + return this.ySlugs + ? this.ySlugs.split(" ") + : this.dimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.columnSlug) + } - // #endregion + @computed get yColumnSlug(): string | undefined { + return this.ySlugs + ? this.ySlugs.split(" ")[0] + : this.getSlugForProperty(DimensionProperty.y) + } - // #region MapChartManager props + @computed get xColumnSlug(): string | undefined { + return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) + } - @computed get mapColumnSlug(): string { - const mapColumnSlug = this.map.columnSlug - // If there's no mapColumnSlug or there is one but it's not in the dimensions array, use the first ycolumn - if ( - !mapColumnSlug || - !this.dimensions.some((dim) => dim.columnSlug === mapColumnSlug) - ) - return this.yColumnSlug! - return mapColumnSlug + @computed get sizeColumnSlug(): string | undefined { + return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) } - @computed get mapIsClickable(): boolean { + @computed get colorColumnSlug(): string | undefined { return ( - this.hasChartTab && - (this.hasLineChart || this.isScatter) && - !isMobile() + this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) ) } - // tab defined previously - // type defined in interface but not on Grapher - - @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { - if (!this.isLineChart) return false - - let { minTime, maxTime } = this + selection: SelectionArray = new SelectionArray() + // entityType defined previously + // focusArray defined previously + // hidePoints defined in interface but not on Grapher + // startHandleTimeBound defined previously + // hideNoDataSection defined in interface but not on Grapher + @computed get startTime(): Time | undefined { + return findClosestTime(this.times, this.startHandleTimeBound) + } - // if we have a time dimension but the timeline is hidden, - // we always want to use the authored `minTime` and `maxTime`, - // irrespective of the time range the user might have selected - // on the table tab - if (this.hasTimeDimensionButTimelineIsHidden) { - minTime = this.authorsVersion.minTime - maxTime = this.authorsVersion.maxTime + @computed get endTime(): Time | undefined { + return findClosestTime(this.times, this.endHandleTimeBound) + } + // facetStrategy defined previously + // seriesStrategy defined in interface but not on Grapher + @computed get _sortConfig(): Readonly { + return { + sortBy: this.sortBy ?? SortBy.total, + sortOrder: this.sortOrder ?? SortOrder.desc, + sortColumnSlug: this.sortColumnSlug, } + } - // This is the easy case: minTime and maxTime are the same, no need to do - // more fancy checks - if (minTime === maxTime) return true - - // We can have cases where minTime = Infinity and/or maxTime = -Infinity, - // but still only a single year is selected. - // To check for that we need to look at the times array. - const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues - const closestMinTime = findClosestTime(times, minTime ?? -Infinity) - const closestMaxTime = findClosestTime(times, maxTime ?? Infinity) - return closestMinTime !== undefined && closestMinTime === closestMaxTime + @computed get sortConfig(): SortConfig { + const sortConfig = { ...this._sortConfig } + // In relative mode, where the values for every entity sum up to 100%, sorting by total + // doesn't make sense. It's also jumpy because of some rounding errors. For this reason, + // we sort by entity name instead. + // Marimekko charts are special and there we don't do this forcing of sort order + if ( + !this.isMarimekko && + this.isRelativeMode && + sortConfig.sortBy === SortBy.total + ) { + sortConfig.sortBy = SortBy.entityName + sortConfig.sortOrder = SortOrder.asc + } + return sortConfig + } + // showNoDataArea defined previously + // externalLegendHoverBin defined in interface but not on Grapher + @computed get disableIntroAnimation(): boolean { + return this.isStatic + } + // missingDataStrategy defined previously + @computed get isNarrow(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 420 } - // hasTimeline defined previously + @computed get isStatic(): boolean { + return this.renderToStatic || this.isExportingToSvgOrPng + } - @action.bound resetHandleTimeBounds(): void { - this.startHandleTimeBound = this.timelineMinTime ?? -Infinity - this.endHandleTimeBound = this.timelineMaxTime ?? Infinity + @computed get isSemiNarrow(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 550 } - @computed get mapConfig(): MapConfig { - return this.map + @computed get isStaticAndSmall(): boolean { + if (!this.isStatic) return false + return this.areStaticBoundsSmall + } + // isExportingForSocialMedia defined previously + @computed get backgroundColor(): Color { + return this.isExportingForSocialMedia + ? GRAPHER_BACKGROUND_BEIGE + : GRAPHER_BACKGROUND_DEFAULT } - // endTime defined previously - // title defined previously - // #endregion + @computed get shouldPinTooltipToBottom(): boolean { + return this.isNarrow && this.isTouchDevice + } - // #region SlopeChartManager props - // canSelectMultipleEntities defined previously - // hasTimeline defined previously - // hideNoDataSection defined in interface but not on Grapher - // #endregion + // Used for superscript numbers in static exports + @computed get detailsOrderedByReference(): string[] { + if (typeof window === "undefined") return [] - // #region Observable props not in any interface + // extract details from supporting text + const subtitleDetails = !this.hideSubtitle + ? extractDetailsFromSyntax(this.currentSubtitle) + : [] + const noteDetails = !this.hideNote + ? extractDetailsFromSyntax(this.note ?? "") + : [] - @observable.ref _isInFullScreenMode = false + // extract details from axis labels + const yAxisDetails = extractDetailsFromSyntax( + this.yAxisConfig.label || "" + ) + const xAxisDetails = extractDetailsFromSyntax( + this.xAxisConfig.label || "" + ) - @observable.ref windowInnerWidth?: number - @observable.ref windowInnerHeight?: number - @observable.ref chartTab?: GrapherChartType + // text fragments are ordered by appearance + const uniqueDetails = uniq([ + ...subtitleDetails, + ...yAxisDetails, + ...xAxisDetails, + ...noteDetails, + ]) - // TODO: Pass these 5 in as options, don't get them as globals. - isDev = this.props.env === "development" - analytics = new GrapherAnalytics(this.props.env ?? "") - isEditor = - typeof window !== "undefined" && (window as any).isEditor === true + return uniqueDetails + } - seriesColorMap: SeriesColorMap = new Map() - @observable.ref externalQueryParams: QueryParams + @computed get detailsMarkerInSvg(): DetailsMarker { + const { isStatic, shouldIncludeDetailsInStaticExport } = this + return !isStatic + ? "underline" + : shouldIncludeDetailsInStaticExport + ? "superscript" + : "none" + } - private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL - private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + // required derived properties - @observable.ref inputTable: OwidTable + getSlugForProperty(property: DimensionProperty): string | undefined { + return this.dimensions.find((dim) => dim.property === property) + ?.columnSlug + } - @observable.ref legacyConfigAsAuthored: Partial = {} + @observable.ref renderToStatic = false + @computed get areStaticBoundsSmall(): boolean { + const { defaultBounds, staticBounds } = this + const idealPixelCount = defaultBounds.width * defaultBounds.height + const staticPixelCount = staticBounds.width * staticBounds.height + return staticPixelCount < 0.66 * idealPixelCount + } - // stored on Grapher so state is preserved when switching to full-screen mode + @computed get isExportingForSocialMedia(): boolean { + return ( + this.isExportingToSvgOrPng && + this.isStaticAndSmall && + this.isSocialMediaExport + ) + } - @observable.ref renderToStatic = false + @computed get isTouchDevice(): boolean { + return isTouchDevice() + } - @observable.ref isSourcesModalOpen = false - @observable.ref isDownloadModalOpen = false - @observable.ref isEmbedModalOpen = false + @computed get currentSubtitle(): string { + const subtitle = this.subtitle + if (subtitle !== undefined) return subtitle + const yColumns = this.yColumnsFromDimensions + if (yColumns.length === 1) return yColumns[0].def.descriptionShort ?? "" + return "" + } - @observable - private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap @observable shouldIncludeDetailsInStaticExport = true - private hasLoggedGAViewEvent = false - @observable private hasBeenVisible = false - @observable private uncaughtError?: Error - @observable slideShow?: SlideShowController - @observable isShareMenuActive = false + @computed get yColumnsFromDimensions(): CoreColumn[] { + return this.filledDimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.column) + } + + // #endregion ChartManager properties + + // #region AxisManager + // fontSize defined previously + // detailsOrderedByReference defined previously + // #endregion - timelineController = new TimelineController(this) + // CaptionedChartManager interface ommited (only used for testing) - // #endregion + // #region SourcesModalManager props - @computed get activeTab(): GrapherTabName { - if (this.tab === GRAPHER_TAB_OPTIONS.table) - return GRAPHER_TAB_NAMES.Table - if (this.tab === GRAPHER_TAB_OPTIONS.map) - return GRAPHER_TAB_NAMES.WorldMap - if (this.chartTab) return this.chartTab - return this.chartType ?? GRAPHER_TAB_NAMES.LineChart + // Ready to go iff we have retrieved data for every variable associated with the chart + @computed get isReady(): boolean { + return this.whatAreWeWaitingFor === "" } + // adminBaseUrl defined previously + @computed get columnsWithSourcesExtensive(): CoreColumn[] { + const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = + this - @computed get chartType(): GrapherChartType | undefined { - return this.validChartTypes[0] - } - @computed get tableForSelection(): OwidTable { - // This table specifies which entities can be selected in the charts EntitySelectorModal. - // It should contain all entities that can be selected, and none more. - // Depending on the chart type, the criteria for being able to select an entity are - // different; e.g. for scatterplots, the entity needs to (1) not be excluded and - // (2) needs to have data for the x and y dimension. - let table = this.isScatter - ? this.tableAfterAuthorTimelineAndActiveChartTransform - : this.inputTable + // sort y-columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + ) - if (!this.isReady) return table + const columnSlugs = excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) - // Some chart types (e.g. stacked area charts) choose not to show an entity - // with incomplete data. Such chart types define a custom transform function - // to ensure that the entity selector only offers entities that are actually plotted. - if (this.chartInstance.transformTableForSelection) { - table = this.chartInstance.transformTableForSelection(table) - } + return this.inputTable + .getColumns(uniq(columnSlugs)) + .filter( + (column) => !!column.source.name || !isEmpty(column.def.origins) + ) + } - return table + @computed get showAdminControls(): boolean { + return ( + this.isUserLoggedInAsAdmin || + this.isDev || + this.isLocalhost || + this.isStaging + ) } + // isSourcesModalOpen defined previously - /** - * Input table with color and size tolerance applied. - * - * This happens _before_ applying the author's timeline filter to avoid - * accidentally dropping all color values before applying tolerance. - * This is especially important for scatter plots and Marimekko charts, - * where color and size columns are often transformed with infinite tolerance. - * - * Line and discrete bar charts also support a color dimension, but their - * tolerance transformations run in their respective transformTable functions - * since it's more efficient to run them on a table that has been filtered - * by selected entities. - */ - @computed get tableAfterColorAndSizeToleranceApplication(): OwidTable { - let table = this.inputTable + @computed get frameBounds(): Bounds { + return this.useIdealBounds + ? new Bounds(0, 0, this.idealWidth, this.idealHeight) + : new Bounds(0, 0, this.availableWidth, this.availableHeight) + } - if (this.isScatter && this.sizeColumnSlug) { - const tolerance = - table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.sizeColumnSlug, - tolerance - ) - } + // isEmbeddedInAnOwidPage defined previously + // isNarrow defined previously + // fontSize defined previously - if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { - const tolerance = - table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.colorColumnSlug, - tolerance + @computed get whatAreWeWaitingFor(): string { + const { newSlugs, inputTable, dimensions } = this + if (newSlugs.length || dimensions.length === 0) { + const missingColumns = newSlugs.filter( + (slug) => !inputTable.has(slug) ) + return missingColumns.length + ? `Waiting for columns ${missingColumns.join(",")} in table '${ + inputTable.tableSlug + }'. ${inputTable.tableDescription}` + : "" } - - return table + if (dimensions.length > 0 && this.loadingDimensions.length === 0) + return "" + return `Waiting for dimensions ${this.loadingDimensions.join(",")}.` } - // If an author sets a timeline filter run it early in the pipeline so to the charts it's as if the filtered times do not exist - @computed get tableAfterAuthorTimelineFilter(): OwidTable { - const table = this.tableAfterColorAndSizeToleranceApplication + // If we are using new slugs and not dimensions, Grapher is ready. + @computed get newSlugs(): string[] { + const { xSlug, colorSlug, sizeSlug } = this + const ySlugs = this.ySlugs ? this.ySlugs.split(" ") : [] + return excludeUndefined([...ySlugs, xSlug, colorSlug, sizeSlug]) + } - if ( - this.timelineMinTime === undefined && - this.timelineMaxTime === undefined - ) - return table - return table.filterByTimeRange( - this.timelineMinTime ?? -Infinity, - this.timelineMaxTime ?? Infinity + @computed private get loadingDimensions(): ChartDimension[] { + return this.dimensions.filter( + (dim) => !this.inputTable.has(dim.columnSlug) ) } - @computed - get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable { - const table = this.tableAfterAuthorTimelineFilter - if (!this.isReady || !this.isOnChartOrMapTab) return table - - const startMark = performance.now() + @computed get isUserLoggedInAsAdmin(): boolean { + // This cookie is set by visiting ourworldindata.org/identifyadmin on the static site. + // There is an iframe on owid.cloud to trigger a visit to that page. - const transformedTable = this.chartInstance.transformTable(table) + try { + // Cookie access can be restricted by iframe sandboxing, in which case the below code will throw an error + // see https://github.com/owid/owid-grapher/pull/2452 - this.createPerformanceMeasurement( - "chartInstance.transformTable", - startMark - ) - return transformedTable + return !!Cookies.get(CookieKey.isAdmin) + } catch { + return false + } } - - @computed get chartInstance(): ChartInterface { - // Note: when timeline handles on a LineChart are collapsed into a single handle, the - // LineChart turns into a DiscreteBar. - - return this.isOnMapTab - ? new MapChart({ manager: this }) - : this.chartInstanceExceptMap + @computed get isDev(): boolean { + return this.initialOptions.env === "dev" } - // When Map becomes a first-class chart instance, we should drop this - @computed get chartInstanceExceptMap(): ChartInterface { - const chartTypeName = - this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart + private get isStaging(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("staging") + } - const ChartClass = - ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass - return new ChartClass({ manager: this }) + private get isLocalhost(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("localhost") } - @computed get chartSeriesNames(): SeriesName[] { - if (!this.isReady) return [] + @computed private get useIdealBounds(): boolean { + const { + isEditor, + isExportingToSvgOrPng, + externalBounds, + widthForDeviceOrientation, + heightForDeviceOrientation, + isInIFrame, + isInFullScreenMode, + windowInnerWidth, + windowInnerHeight, + } = this - // collect series names from all chart instances when faceted - if (this.isFaceted) { - const facetChartInstance = new FacetChart({ manager: this }) - return uniq( - facetChartInstance.intermediateChartInstances.flatMap( - (chartInstance) => - chartInstance.series.map((series) => series.seriesName) - ) + // In full-screen mode, we usually use all space available to us + // We use the ideal bounds only if the available space is very large + if (isInFullScreenMode) { + if ( + windowInnerHeight! > 2 * heightForDeviceOrientation && + windowInnerWidth! > 2 * widthForDeviceOrientation ) + return true + return false } - return this.chartInstance.series.map((series) => series.seriesName) - } - - @computed - private get tableAfterAllTransformsAndFilters(): OwidTable { - const { startTime, endTime } = this - const table = this.tableAfterAuthorTimelineAndActiveChartTransform + // For these, defer to the bounds that are set externally + if ( + this.isEmbeddedInADataPage || + this.isEmbeddedInAnOwidPage || + this.manager || + isInIFrame + ) + return false - if (startTime === undefined || endTime === undefined) return table + // If the user is using interactive version and then goes to export chart, use current bounds to maintain WYSIWYG + if (isExportingToSvgOrPng) return false - if (this.isOnMapTab) - return table.filterByTargetTimes( - [endTime], - this.map.timeTolerance ?? - table.get(this.mapColumnSlug).tolerance - ) + // In the editor, we usually want ideal bounds, except when we're rendering a static preview; + // in that case, we want to use the given static bounds + if (isEditor) return !this.renderToStatic + // If the available space is very small, we use all of the space given to us if ( - this.isDiscreteBar || - this.isLineChartThatTurnedIntoDiscreteBar || - this.isMarimekko + externalBounds.height < heightForDeviceOrientation || + externalBounds.width < widthForDeviceOrientation ) - return table.filterByTargetTimes( - [endTime], - table.get(this.yColumnSlugs[0]).tolerance - ) + return false - if (this.isOnSlopeChartTab) - return table.filterByTargetTimes( - [startTime, endTime], - table.get(this.yColumnSlugs[0]).tolerance - ) + return true + } + + @computed private get widthForDeviceOrientation(): number { + return this.isPortrait ? 400 : 680 + } - return table.filterByTimeRange(startTime, endTime) + @computed private get heightForDeviceOrientation(): number { + return this.isPortrait ? 640 : 480 } - private get isStaging(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("staging") + isEditor = + typeof window !== "undefined" && (window as any).isEditor === true + @computed private get externalBounds(): Bounds { + return this.initialOptions.bounds ?? DEFAULT_BOUNDS } - private get isLocalhost(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("localhost") + @computed get isInIFrame(): boolean { + return isInIFrame() } - /** - * Whether the chart is rendered in an Admin context (e.g. on owid.cloud). - */ - @computed get useAdminAPI(): boolean { - if (typeof window === "undefined") return false + @computed get isInFullScreenMode(): boolean { + return this._isInFullScreenMode + } + + @observable.ref windowInnerWidth?: number + @observable.ref windowInnerHeight?: number + + @computed get isPortrait(): boolean { return ( - window.admin !== undefined && - // Ensure that we're not accidentally matching on a DOM element with an ID of "admin" - typeof window.admin.isSuperuser === "boolean" + this.externalBounds.width < this.externalBounds.height && + this.externalBounds.width < DEFAULT_GRAPHER_WIDTH ) } - @computed get isUserLoggedInAsAdmin(): boolean { - // This cookie is set by visiting ourworldindata.org/identifyadmin on the static site. - // There is an iframe on owid.cloud to trigger a visit to that page. + @observable.ref _isInFullScreenMode = false + @computed private get idealWidth(): number { + return Math.floor(this.widthForDeviceOrientation * this.scaleToFitIdeal) + } - try { - // Cookie access can be restricted by iframe sandboxing, in which case the below code will throw an error - // see https://github.com/owid/owid-grapher/pull/2452 + @computed private get idealHeight(): number { + return Math.floor( + this.heightForDeviceOrientation * this.scaleToFitIdeal + ) + } + @computed private get availableWidth(): number { + const { + externalBounds, + isInFullScreenMode, + windowInnerWidth, + fullScreenPadding, + } = this - return !!Cookies.get(CookieKey.isAdmin) - } catch { - return false - } + return Math.floor( + isInFullScreenMode + ? windowInnerWidth! - 2 * fullScreenPadding + : externalBounds.width + ) } - @action.bound private applyOriginalFocusAsAuthored(): void { - if (this.focusedSeriesNames?.length) - this.focusArray.clearAllAndAdd(...this.focusedSeriesNames) + @computed private get availableHeight(): number { + const { + externalBounds, + isInFullScreenMode, + windowInnerHeight, + fullScreenPadding, + } = this + + return Math.floor( + isInFullScreenMode + ? windowInnerHeight! - 2 * fullScreenPadding + : externalBounds.height + ) } - @computed get hasData(): boolean { - return this.dimensions.length > 0 || this.newSlugs.length > 0 + // If we have a big screen to be in, we can define our own aspect ratio and sit in the center + @computed private get scaleToFitIdeal(): number { + return Math.min( + (this.availableWidth * 0.95) / this.widthForDeviceOrientation, + (this.availableHeight * 0.95) / this.heightForDeviceOrientation + ) } - @computed get whatAreWeWaitingFor(): string { - const { newSlugs, inputTable, dimensions } = this - if (newSlugs.length || dimensions.length === 0) { - const missingColumns = newSlugs.filter( - (slug) => !inputTable.has(slug) - ) - return missingColumns.length - ? `Waiting for columns ${missingColumns.join(",")} in table '${ - inputTable.tableSlug - }'. ${inputTable.tableDescription}` - : "" - } - if (dimensions.length > 0 && this.loadingDimensions.length === 0) - return "" - return `Waiting for dimensions ${this.loadingDimensions.join(",")}.` + @computed private get fullScreenPadding(): number { + const { windowInnerWidth } = this + if (!windowInnerWidth) return 0 + return windowInnerWidth < 940 ? 0 : 40 } - // If we are using new slugs and not dimensions, Grapher is ready. - @computed get newSlugs(): string[] { - const { xSlug, colorSlug, sizeSlug } = this - const ySlugs = this.ySlugs ? this.ySlugs.split(" ") : [] - return excludeUndefined([...ySlugs, xSlug, colorSlug, sizeSlug]) + // #endregion + + // #region DownloadModalManager + @computed get displaySlug(): string { + return this.slug ?? slugify(this.displayTitle) } - @computed private get loadingDimensions(): ChartDimension[] { - return this.dimensions.filter( - (dim) => !this.inputTable.has(dim.columnSlug) - ) + rasterize(): Promise { + const { width, height } = this.staticBoundsWithDetails + const staticSVG = this.generateStaticSvg() + + return new StaticChartRasterizer(staticSVG, width, height).render() + } + // required derived properties + @computed get displayTitle(): string { + if (this.title) return this.title + if (this.isReady) return this.defaultTitle + return "" } + @computed private get defaultTitle(): string { + const yColumns = this.yColumnsFromDimensionsOrSlugsOrAuto - @computed get isInIFrame(): boolean { - return isInIFrame() + if (this.isScatter) + return this.axisDimensions + .map( + (dimension) => + dimension.column.titlePublicOrDisplayName.title + ) + .join(" vs. ") + + const uniqueDatasetNames = uniq( + excludeUndefined( + yColumns.map((col) => (col.def as OwidColumnDef).datasetName) + ) + ) + + if (this.hasMultipleYColumns && uniqueDatasetNames.length === 1) + return uniqueDatasetNames[0] + + if (yColumns.length === 2) + return yColumns + .map((col) => col.titlePublicOrDisplayName.title) + .join(" and ") + + return yColumns + .map((col) => col.titlePublicOrDisplayName.title) + .join(", ") } - /** - * Plots time on the x-axis. - */ - @computed private get hasTimeDimension(): boolean { - return this.isStackedBar || this.isStackedArea || this.isLineChart + @computed private get axisDimensions(): ChartDimension[] { + return this.filledDimensions.filter( + (dim) => + dim.property === DimensionProperty.y || + dim.property === DimensionProperty.x + ) } - @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { - return this.hasTimeDimension && !!this.hideTimeline + @computed get hasMultipleYColumns(): boolean { + return this.yColumnSlugs.length > 1 } - @computed private get validDimensions(): ChartDimension[] { - const { dimensions } = this - const validProperties = this.dimensionSlots.map((d) => d.property) - let validDimensions = dimensions.filter((dim) => - validProperties.includes(dim.property) + generateStaticSvg(): string { + const _isExportingToSvgOrPng = this.isExportingToSvgOrPng + this.isExportingToSvgOrPng = true + const staticSvg = ReactDOMServer.renderToStaticMarkup( + ) + this.isExportingToSvgOrPng = _isExportingToSvgOrPng + return staticSvg + } - this.dimensionSlots.forEach((slot) => { - if (!slot.allowMultiple) - validDimensions = uniqWith( - validDimensions, - ( - a: OwidChartDimensionInterface, - b: OwidChartDimensionInterface - ) => - a.property === slot.property && - a.property === b.property + // staticBounds defined previously + + @computed get staticBoundsWithDetails(): Bounds { + const includeDetails = + this.shouldIncludeDetailsInStaticExport && + !isEmpty(this.detailRenderers) + + let height = this.staticBounds.height + if (includeDetails) { + height += + 2 * this.framePaddingVertical + + sumTextWrapHeights( + this.detailRenderers, + STATIC_EXPORT_DETAIL_SPACING ) - }) + } - return validDimensions + return new Bounds(0, 0, this.staticBounds.width, height) } - // todo: do we need this? - @computed get originUrlWithProtocol(): string { - if (!this.originUrl) return "" - let url = this.originUrl - if (!url.startsWith("http")) url = `https://${url}` - return url + @computed get staticFormat(): GrapherStaticFormat { + return this._staticFormat } - @computed get timelineHandleTimeBounds(): TimeBounds { - if (this.isOnMapTab) { - const time = maxTimeBoundFromJSONOrPositiveInfinity(this.map.time) - return [time, time] - } - - // If the timeline is hidden on the chart tab but displayed on the table tab - // (which is the case for charts that plot time on the x-axis), - // we always want to use the authored `minTime` and `maxTime` for the chart, - // irrespective of the time range the user might have selected on the table tab - if (this.isOnChartTab && this.hasTimeDimensionButTimelineIsHidden) { - const { minTime, maxTime } = this.authorsVersion - return [ - minTimeBoundFromJSONOrNegativeInfinity(minTime), - maxTimeBoundFromJSONOrPositiveInfinity(maxTime), - ] - } + @computed get baseUrl(): string | undefined { + return this.isPublished + ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` + : undefined + } + // queryStr defined previously + // table defined previously + // transformedTable defined previously - return [ - // Handle `undefined` values in minTime/maxTime - minTimeBoundFromJSONOrNegativeInfinity(this.minTime), - maxTimeBoundFromJSONOrPositiveInfinity(this.maxTime), - ] + // todo: remove when we remove dimensions + @computed get yColumnsFromDimensionsOrSlugsOrAuto(): CoreColumn[] { + return this.yColumnsFromDimensions.length + ? this.yColumnsFromDimensions + : this.table.getColumns(autoDetectYColumnSlugs(this)) } + // shouldIncludeDetailsInStaticExport defined previously + // detailsOrderedByReference defined previously + // isDownloadModalOpen defined previously + // frameBounds defined previously - @computed private get onlySingleTimeSelectionPossible(): boolean { - return ( - this.isDiscreteBar || - this.isStackedDiscreteBar || - this.isOnMapTab || - this.isMarimekko + @computed get captionedChartBounds(): Bounds { + // if there's no panel, the chart takes up the whole frame + if (!this.isEntitySelectorPanelActive) return this.frameBounds + + return new Bounds( + 0, + 0, + // the chart takes up 9 columns in 12-column grid + (9 / 12) * this.frameBounds.width, + this.frameBounds.height - 2 // 2px accounts for the border ) } - @computed private get isSingleTimeSelectionActive(): boolean { - return ( - this.onlySingleTimeSelectionPossible || - this.isSingleTimeScatterAnimationActive - ) + @computed get isOnChartOrMapTab(): boolean { + return this.isOnChartTab || this.isOnMapTab } + // showAdminControls defined previously + // isSocialMediaExport defined previously + // isPublished defined previously + // Columns that are used as a dimension in the currently active view + @computed get activeColumnSlugs(): string[] { + const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = + this - @computed get shouldLinkToOwid(): boolean { - if ( - this.isEmbeddedInAnOwidPage || - this.isExportingToSvgOrPng || - !this.isInIFrame + // sort y columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title ) - return false - return true - } - - @computed.struct private get variableIds(): number[] { - return uniq(this.dimensions.map((d) => d.variableId)) + return excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) } - @computed get hasOWIDLogo(): boolean { + @computed get isEntitySelectorPanelActive(): boolean { return ( - !this.hideLogo && (this.logo === undefined || this.logo === "owid") + !this.hideEntityControls && + this.canChangeAddOrHighlightEntities && + this.isOnChartTab && + this.showEntitySelectorAs === GrapherWindowType.panel ) } - // todo: did this name get botched in a merge? - @computed get hasFatalErrors(): boolean { - const { relatedQuestions = [] } = this - return relatedQuestions.some( - (question) => !!getErrorMessageRelatedQuestionUrl(question) + private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + + @computed get showEntitySelectorAs(): GrapherWindowType { + if ( + this.frameBounds.width > 940 && + // don't use the panel if the grapher is embedded + ((!this.isInIFrame && !this.isEmbeddedInAnOwidPage) || + // unless we're in full-screen mode + this.isInFullScreenMode) ) + return GrapherWindowType.panel + + return this.isSemiNarrow + ? GrapherWindowType.modal + : GrapherWindowType.drawer } - // Get the dimension slots appropriate for this type of chart - @computed get dimensionSlots(): DimensionSlot[] { - const xAxis = new DimensionSlot(this, DimensionProperty.x) - const yAxis = new DimensionSlot(this, DimensionProperty.y) - const color = new DimensionSlot(this, DimensionProperty.color) - const size = new DimensionSlot(this, DimensionProperty.size) + // 2025-01-01 These properties are required for the CaptionedChart interface. I + // assumed that this was only used in testing but the static export also makes use of this. + // Might necessitate another round of filling in that interface. + @action.bound setTab(newTab: GrapherTabName): void { + if (newTab === GRAPHER_TAB_NAMES.Table) { + this.tab = GRAPHER_TAB_OPTIONS.table + this.chartTab = undefined + } else if (newTab === GRAPHER_TAB_NAMES.WorldMap) { + this.tab = GRAPHER_TAB_OPTIONS.map + this.chartTab = undefined + } else { + this.tab = GRAPHER_TAB_OPTIONS.chart + this.chartTab = newTab + } + } - if (this.isLineChart || this.isDiscreteBar) return [yAxis, color] - else if (this.isScatter) return [yAxis, xAxis, size, color] - else if (this.isMarimekko) return [yAxis, xAxis, color] - return [yAxis] + @action.bound onTabChange( + oldTab: GrapherTabName, + newTab: GrapherTabName + ): void { + // if switching from a line to a slope chart and the handles are + // on the same time, then automatically adjust the handles so that + // the slope chart view is meaningful + if ( + oldTab === GRAPHER_TAB_NAMES.LineChart && + newTab === GRAPHER_TAB_NAMES.SlopeChart && + this.areHandlesOnSameTime + ) { + if (this.startHandleTimeBound !== -Infinity) { + this.startHandleTimeBound = -Infinity + } else { + this.endHandleTimeBound = Infinity + } + } } // Used for static exports. Defined at this level because they need to @@ -1893,56 +1968,104 @@ export class Grapher }) } - @computed get hasProjectedData(): boolean { - return this.inputTable.numericColumnSlugs.some( - (slug) => this.inputTable.get(slug).isProjection + @computed private get areHandlesOnSameTime(): boolean { + const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues + const [start, end] = this.timelineHandleTimeBounds.map((time) => + findClosestTime(times, time) ) + return start === end } - @computed get validChartTypes(): GrapherChartType[] { - const { chartTypes } = this + set startHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + newValue, + this.timelineHandleTimeBounds[1], + ] + } - // all single-chart Graphers are valid - if (chartTypes.length <= 1) return chartTypes + set endHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + this.timelineHandleTimeBounds[0], + newValue, + ] + } - // find valid combination in a pre-defined list - const validChartTypes = findValidChartTypeCombination(chartTypes) + @computed get secondaryColorInStaticCharts(): Color { + return this.isStaticAndSmall ? GRAPHER_LIGHT_TEXT : GRAPHER_DARK_TEXT + } - // if the given combination is not valid, then ignore all but the first chart type - if (!validChartTypes) return chartTypes.slice(0, 1) + // #endregion - // projected data is only supported for line charts - const isLineChart = validChartTypes[0] === GRAPHER_CHART_TYPES.LineChart - if (isLineChart && this.hasProjectedData) { - return [GRAPHER_CHART_TYPES.LineChart] - } + // #region DiscreteBarChartManager props - return validChartTypes - } + // showYearLabels defined previously + // endTime defined previously - @computed get validChartTypeSet(): Set { - return new Set(this.validChartTypes) + @computed get isOnLineChartTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.LineChart } + // #endregion - @computed get availableTabs(): GrapherTabName[] { - const availableTabs: GrapherTabName[] = [] - if (this.hasTableTab) availableTabs.push(GRAPHER_TAB_NAMES.Table) - if (this.hasMapTab) availableTabs.push(GRAPHER_TAB_NAMES.WorldMap) - if (!this.hideChartTabs) availableTabs.push(...this.validChartTypes) - return availableTabs + // LegacyDimensionsManager omitted (only defines table) + + // #region ShareMenuManager props + // slug defined previously + + @computed get currentTitle(): string { + let text = this.displayTitle.trim() + if (text.length === 0) return text + + // helper function to add an annotation fragment to the title + // only adds a comma if the text does not end with a question mark + const appendAnnotationField = ( + text: string, + annotation: string + ): string => { + const separator = text.endsWith("?") ? "" : "," + return `${text}${separator} ${annotation}` + } + + if (this.shouldAddEntitySuffixToTitle) { + const selectedEntityNames = this.selection.selectedEntityNames + const entityStr = selectedEntityNames[0] + if (entityStr?.length) text = appendAnnotationField(text, entityStr) + } + + if (this.shouldAddChangeInPrefixToTitle) + text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) + + if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) + text = appendAnnotationField(text, this.timeTitleSuffix) + + return text.trim() } - @computed get hasMultipleChartTypes(): boolean { - return this.validChartTypes.length > 1 + // Get the full url representing the canonical location of this grapher state + @computed get canonicalUrl(): string | undefined { + return ( + this.manager?.canonicalUrl ?? + this.canonicalUrlIfIsChartView ?? + (this.baseUrl ? this.baseUrl + this.queryStr : undefined) + ) } - @computed get currentSubtitle(): string { - const subtitle = this.subtitle - if (subtitle !== undefined) return subtitle - const yColumns = this.yColumnsFromDimensions - if (yColumns.length === 1) return yColumns[0].def.descriptionShort ?? "" - return "" + @computed get editUrl(): string | undefined { + if (this.showAdminControls) { + return `${this.adminBaseUrl}/admin/${ + this.manager?.editUrl ?? `charts/${this.id}/edit` + }` + } + return undefined } + // isEmbedModalOpen defined previously + + // required derived properties @computed get shouldAddEntitySuffixToTitle(): boolean { const selectedEntityNames = this.selection.selectedEntityNames @@ -1962,7 +2085,33 @@ export class Grapher this.canSelectMultipleEntities) ) } - + + @computed get shouldAddChangeInPrefixToTitle(): boolean { + const showChangeInPrefix = + !this.hideAnnotationFieldsInTitle?.changeInPrefix + return ( + !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && + (this.isOnLineChartTab || this.isOnSlopeChartTab) && + this.isRelativeMode && + showChangeInPrefix + ) + } + + @computed get canonicalUrlIfIsChartView(): string | undefined { + if (!this.chartViewInfo) return undefined + + const { parentChartSlug, queryParamsForParentChart } = + this.chartViewInfo + + const combinedQueryParams = { + ...queryParamsForParentChart, + ...this.changedParams, + } + + return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( + combinedQueryParams + )}` + } @computed get shouldAddTimeSuffixToTitle(): boolean { const showTimeAnnotation = !this.hideAnnotationFieldsInTitle?.time return ( @@ -1979,39 +2128,6 @@ export class Grapher ) } - @computed get shouldAddChangeInPrefixToTitle(): boolean { - const showChangeInPrefix = - !this.hideAnnotationFieldsInTitle?.changeInPrefix - return ( - !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && - (this.isOnLineChartTab || this.isOnSlopeChartTab) && - this.isRelativeMode && - showChangeInPrefix - ) - } - - @computed private get areHandlesOnSameTime(): boolean { - const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues - const [start, end] = this.timelineHandleTimeBounds.map((time) => - findClosestTime(times, time) - ) - return start === end - } - - @computed get yColumnsFromDimensions(): CoreColumn[] { - return this.filledDimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.column) - } - - @computed get yScaleType(): ScaleType | undefined { - return this.yAxis.scaleType - } - - @computed get xScaleType(): ScaleType | undefined { - return this.xAxis.scaleType - } - @computed private get timeTitleSuffix(): string | undefined { const timeColumn = this.table.timeColumn if (timeColumn.isMissing) return undefined // Do not show year until data is loaded @@ -2028,358 +2144,490 @@ export class Grapher return time } - @computed get sourcesLine(): string { - return this.sourceDesc ?? this.defaultSourcesLine + // #endregion + + // #region EmbedModalManager props + // canonicalUrl defined previously + @computed get embedUrl(): string | undefined { + const url = this.manager?.embedDialogUrl ?? this.canonicalUrl + if (!url) return + + // We want to preserve the tab in the embed URL so that if we change the + // default view of the chart, it won't change existing embeds. + // 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 }) + } + return urlObj.fullUrl } - @computed get columnsWithSourcesCondensed(): CoreColumn[] { - const { yColumnSlugs } = this + @computed get embedDialogAdditionalElements(): + | React.ReactElement + | undefined { + return this.manager?.embedDialogAdditionalElements + } + // isEmbedModalOpen defined previously + // frameBounds defined previously + // #endregion - const columnSlugs = [...yColumnSlugs] - columnSlugs.push(...this.getColumnSlugsForCondensedSources()) + // TooltipManager omitted (only defines tooltip) - return this.inputTable - .getColumns(uniq(columnSlugs)) - .filter( - (column) => !!column.source.name || !isEmpty(column.def.origins) - ) + // #region DataTableManager props + // table defined previously + // table that is used for display in the table tab + @computed get tableForDisplay(): OwidTable { + const table = this.table + if (!this.isReady || !this.isOnTableTab) return table + return this.chartInstance.transformTableForDisplay + ? this.chartInstance.transformTableForDisplay(table) + : table } + // entityType defined previously + // endTime defined previously + // startTime defined previously - @computed private get defaultSourcesLine(): string { - const attributions = this.columnsWithSourcesCondensed.flatMap( - (column) => { - const { presentation = {} } = column.def - // if the variable metadata specifies an attribution on the - // variable level then this is preferred over assembling it from - // the source and origins - if ( - presentation.attribution !== undefined && - presentation.attribution !== "" - ) - return [presentation.attribution] - else { - const originFragments = getOriginAttributionFragments( - column.def.origins - ) - return [column.source.name, ...originFragments] - } - } - ) + @computed get dataTableSlugs(): ColumnSlug[] { + return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs + } - const uniqueAttributions = uniq(compact(attributions)) + @observable.ref showSelectionOnlyInDataTable?: boolean = undefined - if (uniqueAttributions.length > 3) - return `${uniqueAttributions[0]} and other sources` + @computed get entitiesAreCountryLike(): boolean { + return !!this.entityType.match(/\bcountry\b/i) + } + // Small charts are rendered into 6 or 7 columns in a 12-column grid layout + // (e.g. side-by-side charts or charts in the All Charts block) + @computed get isSmall(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 740 + } - return uniqueAttributions.join("; ") + // Medium charts are rendered into 8 columns in a 12-column grid layout + // (e.g. stand-alone charts in the main text of an article) + @computed get isMedium(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 845 } + // isNarrow defined previoulsy + // selection defined previously - @computed private get axisDimensions(): ChartDimension[] { - return this.filledDimensions.filter( - (dim) => - dim.property === DimensionProperty.y || - dim.property === DimensionProperty.x + @computed get canChangeAddOrHighlightEntities(): boolean { + return ( + this.canChangeEntity || + this.canAddEntities || + this.canHighlightEntities ) } + // hasMapTab defined previously + @computed get hasChartTab(): boolean { + return this.validChartTypes.length > 0 + } + @computed get validChartTypes(): GrapherChartType[] { + const { chartTypes } = this - @computed private get defaultTitle(): string { - const yColumns = this.yColumnsFromDimensionsOrSlugsOrAuto - - if (this.isScatter) - return this.axisDimensions - .map( - (dimension) => - dimension.column.titlePublicOrDisplayName.title - ) - .join(" vs. ") + // all single-chart Graphers are valid + if (chartTypes.length <= 1) return chartTypes - const uniqueDatasetNames = uniq( - excludeUndefined( - yColumns.map((col) => (col.def as OwidColumnDef).datasetName) - ) - ) + // find valid combination in a pre-defined list + const validChartTypes = findValidChartTypeCombination(chartTypes) - if (this.hasMultipleYColumns && uniqueDatasetNames.length === 1) - return uniqueDatasetNames[0] + // if the given combination is not valid, then ignore all but the first chart type + if (!validChartTypes) return chartTypes.slice(0, 1) - if (yColumns.length === 2) - return yColumns - .map((col) => col.titlePublicOrDisplayName.title) - .join(" and ") + // projected data is only supported for line charts + const isLineChart = validChartTypes[0] === GRAPHER_CHART_TYPES.LineChart + if (isLineChart && this.hasProjectedData) { + return [GRAPHER_CHART_TYPES.LineChart] + } - return yColumns - .map((col) => col.titlePublicOrDisplayName.title) - .join(", ") + return validChartTypes } - @computed get displayTitle(): string { - if (this.title) return this.title - if (this.isReady) return this.defaultTitle - return "" + @computed get validChartTypeSet(): Set { + return new Set(this.validChartTypes) } - // Returns an object ready to be serialized to JSON - @computed get object(): GrapherInterface { - return this.toObject() + @computed get availableTabs(): GrapherTabName[] { + const availableTabs: GrapherTabName[] = [] + if (this.hasTableTab) availableTabs.push(GRAPHER_TAB_NAMES.Table) + if (this.hasMapTab) availableTabs.push(GRAPHER_TAB_NAMES.WorldMap) + if (!this.hideChartTabs) availableTabs.push(...this.validChartTypes) + return availableTabs } - @computed - get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { - return this.isLineChartThatTurnedIntoDiscreteBarActive - ? GRAPHER_CHART_TYPES.DiscreteBar - : this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart + @computed get hasMultipleChartTypes(): boolean { + return this.validChartTypes.length > 1 } - @computed get isLineChart(): boolean { + // required derived properties + + @computed get canAddEntities(): boolean { return ( - this.chartType === GRAPHER_CHART_TYPES.LineChart || !this.chartType + this.hasChartTab && + this.canSelectMultipleEntities && + (this.isOnLineChartTab || + this.isOnSlopeChartTab || + this.isOnStackedAreaTab || + this.isOnStackedBarTab || + this.isOnDiscreteBarTab || + this.isOnStackedDiscreteBarTab) ) } - @computed get isScatter(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.ScatterPlot - } - @computed get isStackedArea(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.StackedArea - } - @computed get isSlopeChart(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.SlopeChart - } - @computed get isDiscreteBar(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.DiscreteBar - } - @computed get isStackedBar(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.StackedBar + + @computed get hasProjectedData(): boolean { + return this.inputTable.numericColumnSlugs.some( + (slug) => this.inputTable.get(slug).isProjection + ) } - @computed get isMarimekko(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.Marimekko + + // #endregion DataTableManager props + + // #region ScatterPlotManager props + // hideConnectedScatterLines defined previously + // scatterPointLabelStrategy defined previously + // addCountryMode defined previously + + // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? + // todo: remove this. Should be done as a simple column transform at the data level. + // Possible to override the x axis dimension to target a special year + // In case you want to graph say, education in the past and democracy today https://ourworldindata.org/grapher/correlation-between-education-and-democracy + @computed get xOverrideTime(): number | undefined { + return this.xDimension?.targetYear } - @computed get isStackedDiscreteBar(): boolean { - return this.chartType === GRAPHER_CHART_TYPES.StackedDiscreteBar + // tableAfterAuthorTimelineAndActiveChartTransform defined below (together with other table transforms) + + /** + * Uses some explicit and implicit information to decide whether a timeline is shown. + */ + @computed get hasTimeline(): boolean { + // we don't have more than one distinct time point in our data, so it doesn't make sense to show a timeline + if (this.times.length <= 1) return false + + switch (this.tab) { + // the map tab has its own `hideTimeline` option + case GRAPHER_TAB_OPTIONS.map: + return !this.map.hideTimeline + + // use the chart-level `hideTimeline` option + case GRAPHER_TAB_OPTIONS.chart: + return !this.hideTimeline + + // use the chart-level `hideTimeline` option for the table, with some exceptions + case GRAPHER_TAB_OPTIONS.table: + // always show the timeline for charts that plot time on the x-axis + if (this.hasTimeDimension) return true + return !this.hideTimeline + + default: + return false + } } - @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { + @computed get isModalOpen(): boolean { return ( - this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar + this.isEntitySelectorModalOpen || + this.isSourcesModalOpen || + this.isEmbedModalOpen || + this.isDownloadModalOpen ) } - @computed get isOnScatterTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.ScatterPlot - } - @computed get isOnStackedAreaTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.StackedArea - } - @computed get isOnSlopeChartTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.SlopeChart - } - @computed get isOnDiscreteBarTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.DiscreteBar - } - @computed get isOnStackedBarTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.StackedBar - } - @computed get isOnMarimekkoTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.Marimekko - } - @computed get isOnStackedDiscreteBarTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.StackedDiscreteBar + @computed get isSingleTimeScatterAnimationActive(): boolean { + return ( + this.isTimelineAnimationActive && + this.isOnScatterTab && + !this.isRelativeMode && + !!this.areHandlesOnSameTimeBeforeAnimation + ) } - @computed get hasLineChart(): boolean { - return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.LineChart) - } - @computed get hasSlopeChart(): boolean { - return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.SlopeChart) + @observable.ref animationStartTime?: Time + @computed get animationEndTime(): Time { + const { timeColumn } = this.tableAfterAuthorTimelineFilter + if (this.timelineMaxTime) { + return ( + findClosestTime(timeColumn.uniqValues, this.timelineMaxTime) ?? + timeColumn.maxTime + ) + } + return timeColumn.maxTime } - @computed get supportsMultipleYColumns(): boolean { - return !this.isScatter - } + // required derived properties - @computed private get xDimension(): ChartDimension | undefined { + @computed get xDimension(): ChartDimension | undefined { return this.filledDimensions.find( (d) => d.property === DimensionProperty.x ) } - @computed get defaultBounds(): Bounds { - return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT) + @computed get isEntitySelectorModalOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.modal + ) } - @computed get hasYDimension(): boolean { - return this.dimensions.some((d) => d.property === DimensionProperty.y) + @computed get isEntitySelectorDrawerOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.drawer + ) } - @computed get cacheTag(): string { - return this.version.toString() - } + @observable.ref isSourcesModalOpen = false + @observable.ref isDownloadModalOpen = false + @observable.ref isEmbedModalOpen = false - // Filter data to what can be display on the map (across all times) - @computed get mappableData(): OwidVariableRow[] { - return this.inputTable - .get(this.mapColumnSlug) - .owidRows.filter((row) => isOnTheMap(row.entityName)) - } + // #endregion ScatterPlotManager props - @computed get isMobile(): boolean { - return isMobile() - } + // #region MarimekkoChartManager props + // endTime defined previously + // excludedEntities defined previously + // matchingEntitiesOnly defined previously + // xOverrideTime defined previously + // tableAfterAuthorTimelineAndActiveChartTransform defined below (together with other table transforms) + // sortConfig defined previously + // hideNoDataArea defined previously + // includedEntities defined previously + // #endregion - @computed get isTouchDevice(): boolean { - return isTouchDevice() + // #region FacetChartManager + + @computed get canSelectMultipleEntities(): boolean { + if (this.numSelectableEntityNames < 2) return false + if (this.addCountryMode === EntitySelectionMode.MultipleEntities) + return true + + if ( + // we force multi-entity selection mode when the chart is faceted + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.facetStrategy !== FacetStrategy.none && + // unless the author explicitly configured the chart to be split + // by metric and hid the facet control + !( + this.facetStrategy === FacetStrategy.metric && + this.hideFacetControl + ) + ) + return true + + return false } - @computed private get externalBounds(): Bounds { - return this.props.bounds ?? DEFAULT_BOUNDS + // #endregion + + // #region EntitySelectorModalManager + + @observable entitySelectorState: Partial = {} + // tableForSeleciton defined below (together with other table transforms) + // selection defined previously + // entityType defined previously + // entityTypePlural defined previously + // activeColumnSlugs defined previously + // dataApiUrl defined previously + + @observable.ref isEntitySelectorModalOrDrawerOpen = false + + @computed get canChangeEntity(): boolean { + return ( + this.hasChartTab && + !this.isOnScatterTab && + !this.canSelectMultipleEntities && + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.numSelectableEntityNames > 1 + ) } - @computed private get isPortrait(): boolean { + @computed get canHighlightEntities(): boolean { return ( - this.externalBounds.width < this.externalBounds.height && - this.externalBounds.width < DEFAULT_GRAPHER_WIDTH + this.hasChartTab && + this.addCountryMode !== EntitySelectionMode.Disabled && + this.numSelectableEntityNames > 1 && + !this.canAddEntities && + !this.canChangeEntity ) } - @computed private get widthForDeviceOrientation(): number { - return this.isPortrait ? 400 : 680 + focusArray = new FocusArray() + + // frameBounds defined previously + + // required derived properties + + // 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 + // we may still want to show those entities as available in a picker. We also do not want to do things like + // hide the Add Entity button as the user drags the timeline. + @computed private get numSelectableEntityNames(): number { + return this.selection.numAvailableEntityNames } - @computed private get heightForDeviceOrientation(): number { - return this.isPortrait ? 640 : 480 + // #endregion + + // #region SettingsMenuManager + + // stackMode defined previously + + @computed get relativeToggleLabel(): string { + if (this.isOnScatterTab) return "Display average annual change" + else if (this.isOnLineChartTab || this.isOnSlopeChartTab) + return "Display relative change" + return "Display relative values" } - @computed private get useIdealBounds(): boolean { - const { - isEditor, - isExportingToSvgOrPng, - externalBounds, - widthForDeviceOrientation, - heightForDeviceOrientation, - isInIFrame, - isInFullScreenMode, - windowInnerWidth, - windowInnerHeight, - } = this + // showNoDataArea defined previously - // In full-screen mode, we usually use all space available to us - // We use the ideal bounds only if the available space is very large - if (isInFullScreenMode) { - if ( - windowInnerHeight! > 2 * heightForDeviceOrientation && - windowInnerWidth! > 2 * widthForDeviceOrientation - ) - return true - return false - } + // facetStrategy defined previously + // yAxis defined previously + // zoomToSelection defined previously + // showSelectedEntitiesOnly defined previously + // entityTypePlural defined previously - // For these, defer to the bounds that are set externally - if ( - this.isEmbeddedInADataPage || - this.isEmbeddedInAnOwidPage || - this.props.manager || - isInIFrame - ) - return false + @computed get availableFacetStrategies(): FacetStrategy[] { + return this.chartInstance.availableFacetStrategies?.length + ? this.chartInstance.availableFacetStrategies + : [FacetStrategy.none] + } - // If the user is using interactive version and then goes to export chart, use current bounds to maintain WYSIWYG - if (isExportingToSvgOrPng) return false + // entityType defined previously + // facettingLabelByYVariables defined previously + // hideFacetControl defined previously + // hideRelativeToggle defined previously + // hideEntityControls defined previously + // hideZoomToggle defined previously + // hideNoDataAreaToggle defined previously + // hideFacetYDomainToggle defined previously + // hideXScaleToggle defined previously + // hideYScaleToggle defined previously + // hideTableFilterToggle defined previously - // In the editor, we usually want ideal bounds, except when we're rendering a static preview; - // in that case, we want to use the given static bounds - if (isEditor) return !this.renderToStatic + @computed get activeChartType(): GrapherChartType | undefined { + if (!this.isOnChartTab) return undefined + return this.activeTab as GrapherChartType + } - // If the available space is very small, we use all of the space given to us + // NB: The timeline scatterplot in relative mode calculates changes relative + // to the lower bound year rather than creating an arrow chart + @computed get isRelativeMode(): boolean { + // don't allow relative mode in some cases if ( - externalBounds.height < heightForDeviceOrientation || - externalBounds.width < widthForDeviceOrientation + this.hasSingleMetricInFacets || + this.hasSingleEntityInFacets || + this.isStackedChartSplitByMetric ) return false - - return true - } - - // If we have a big screen to be in, we can define our own aspect ratio and sit in the center - @computed private get scaleToFitIdeal(): number { - return Math.min( - (this.availableWidth * 0.95) / this.widthForDeviceOrientation, - (this.availableHeight * 0.95) / this.heightForDeviceOrientation - ) + return this.stackMode === StackMode.relative } - @computed private get fullScreenPadding(): number { - const { windowInnerWidth } = this - if (!windowInnerWidth) return 0 - return windowInnerWidth < 940 ? 0 : 40 - } + // selection defined previously + // canChangeAddOrHighlightEntities defined previously - @computed get hideFullScreenButton(): boolean { - if (this.isInFullScreenMode) return false - // hide the full screen button if the full screen height - // is barely larger than the current chart height - const fullScreenHeight = this.windowInnerHeight! - return fullScreenHeight < this.frameBounds.height + 80 + @computed.struct get filledDimensions(): ChartDimension[] { + return this.isReady ? this.dimensions : [] } - @computed private get availableWidth(): number { + // xColumnSlug defined previously + // xOverrideTime defined previously + // hasTimeline defined previously + @computed get canToggleRelativeMode(): boolean { const { - externalBounds, - isInFullScreenMode, - windowInnerWidth, - fullScreenPadding, + isOnLineChartTab, + isOnSlopeChartTab, + hideRelativeToggle, + areHandlesOnSameTime, + yScaleType, + hasSingleEntityInFacets, + hasSingleMetricInFacets, + xColumnSlug, + isOnMarimekkoTab, + isStackedChartSplitByMetric, } = this - return Math.floor( - isInFullScreenMode - ? windowInnerWidth! - 2 * fullScreenPadding - : externalBounds.width + if (isOnLineChartTab || isOnSlopeChartTab) + return ( + !hideRelativeToggle && + !areHandlesOnSameTime && + yScaleType !== ScaleType.log + ) + + // actually trying to exclude relative mode with just one metric or entity + if ( + hasSingleEntityInFacets || + hasSingleMetricInFacets || + isStackedChartSplitByMetric ) - } + return false - @computed private get availableHeight(): number { - const { - externalBounds, - isInFullScreenMode, - windowInnerHeight, - fullScreenPadding, - } = this + if (isOnMarimekkoTab && xColumnSlug === undefined) return false + return !hideRelativeToggle + } - return Math.floor( - isInFullScreenMode - ? windowInnerHeight! - 2 * fullScreenPadding - : externalBounds.height - ) + @computed get isOnChartTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.chart } - @computed private get idealWidth(): number { - return Math.floor(this.widthForDeviceOrientation * this.scaleToFitIdeal) + @computed get isOnMapTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.map } - @computed private get idealHeight(): number { - return Math.floor( - this.heightForDeviceOrientation * this.scaleToFitIdeal - ) + @computed get isOnTableTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.table } - @computed get sidePanelBounds(): Bounds | undefined { - if (!this.isEntitySelectorPanelActive) return + // yAxis defined previously + // xAxis defined previously + // compareEndPointsOnly defined previously - return new Bounds( - 0, // not in use; intentionally set to zero - 0, // not in use; intentionally set to zero - this.frameBounds.width - this.captionedChartBounds.width, - this.captionedChartBounds.height + // availableFacetStrategies defined previously + // the actual facet setting used by a chart, potentially overriding selectedFacetStrategy + @computed get facetStrategy(): FacetStrategy { + if ( + this.selectedFacetStrategy && + this.availableFacetStrategies.includes(this.selectedFacetStrategy) ) - } - @computed get containerElement(): HTMLDivElement | undefined { - return this.base.current || undefined - } + return this.selectedFacetStrategy - @computed get availableEntities(): Entity[] { - return this.tableForSelection.availableEntities - } - @computed get hasMultipleYColumns(): boolean { - return this.yColumnSlugs.length > 1 + if ( + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.selection.selectedEntityNames.length > 1 + ) { + return FacetStrategy.entity + } + + return firstOfNonEmptyArray(this.availableFacetStrategies) } + // entityType defined previously + // facettingLabelByYVariables defined previously + + // hideFacetControl defined previously + // hideRelativeToggle defined previously + // hideEntityControls defined previously + // hideZoomToggle defined previously + // hideNoDataAreaToggle defined previously + // hideFacetYDomainToggle defined previously + // hideXScaleToggle defined previously + // hideYScaleToggle defined previously + // hideTableFilterToggle defined previously + // activeChartType defined previously + // isRelativeMode defined previously + // selection defined previously + // canChangeAddOrHighlightEntities defined previously + // filledDimensions defined previously + // xColumnSlug defined previously + // xOverrideTime defined previously + // hasTimeline defined previously + // canToggleRelativeMode defined previously + // isOnChartTab defined previously + // isOnMapTab defined previously + // isOnTableTab defined previously + // yAxis defined previously + // xAxis defined previously + // compareEndPointsOnly defined previously + + // required derived properties @computed private get hasSingleMetricInFacets(): boolean { const { @@ -2438,156 +2686,96 @@ export class Grapher ) } - @computed get isFaceted(): boolean { - const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none - return this.isOnChartTab && hasFacetStrategy - } - - @computed get isInFullScreenMode(): boolean { - return this._isInFullScreenMode - } - - // the header and footer don't rely on the base font size unless explicitly specified - @computed get useBaseFontSize(): boolean { - return this.props.baseFontSize !== undefined || this.isStatic - } - - @computed get areStaticBoundsSmall(): boolean { - const { defaultBounds, staticBounds } = this - const idealPixelCount = defaultBounds.width * defaultBounds.height - const staticPixelCount = staticBounds.width * staticBounds.height - return staticPixelCount < 0.66 * idealPixelCount + @computed get yScaleType(): ScaleType | undefined { + return this.yAxis.scaleType } - @computed get secondaryColorInStaticCharts(): Color { - return this.isStaticAndSmall ? GRAPHER_LIGHT_TEXT : GRAPHER_DARK_TEXT - } + // #endregion - @computed get isExportingForSocialMedia(): boolean { - return ( - this.isExportingToSvgOrPng && - this.isStaticAndSmall && - this.isSocialMediaExport - ) - } + // #region MapChartManager props - @computed get hasRelatedQuestion(): boolean { + @computed get mapColumnSlug(): string { + const mapColumnSlug = this.map.columnSlug + // If there's no mapColumnSlug or there is one but it's not in the dimensions array, use the first ycolumn if ( - this.hideRelatedQuestion || - !this.relatedQuestions || - !this.relatedQuestions.length - ) - return false - const question = this.relatedQuestions[0] - return !!question && !!question.text && !!question.url - } - - @computed get isRelatedQuestionTargetDifferentFromCurrentPage(): boolean { - // comparing paths rather than full URLs for this to work as - // expected on local and staging where the origin (e.g. - // hans.owid.cloud) doesn't match the production origin that has - // been entered in the related question URL field: - // "ourworldindata.org" and yet should still yield a match. - // - Note that this won't work on production previews (where the - // path is /admin/posts/preview/ID) - const { hasRelatedQuestion, relatedQuestions = [] } = this - const relatedQuestion = relatedQuestions[0] - return ( - hasRelatedQuestion && - !!relatedQuestion && - getWindowUrl().pathname !== - Url.fromURL(relatedQuestion.url).pathname + !mapColumnSlug || + !this.dimensions.some((dim) => dim.columnSlug === mapColumnSlug) ) + return this.yColumnSlug! + return mapColumnSlug } - @computed get showRelatedQuestion(): boolean { + @computed get mapIsClickable(): boolean { return ( - !!this.relatedQuestions && - !!this.hasRelatedQuestion && - !!this.isRelatedQuestionTargetDifferentFromCurrentPage - ) - } - - @computed.struct get allParams(): GrapherQueryParams { - return grapherObjectToQueryParams(this) - } - - @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentSelectedEntityNames = this.selection.selectedEntityNames - const originalSelectedEntityNames = - authoredConfig.selectedEntityNames ?? [] - - return isArrayDifferentFromReference( - currentSelectedEntityNames, - originalSelectedEntityNames - ) - } - - @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentFocusedSeriesNames = this.focusArray.seriesNames - const originalFocusedSeriesNames = - authoredConfig.focusedSeriesNames ?? [] - - return isArrayDifferentFromReference( - currentFocusedSeriesNames, - originalFocusedSeriesNames + this.hasChartTab && + (this.hasLineChart || this.isScatter) && + !isMobile() ) } - // 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) - } - - // If you want to compare current state against the published grapher. - @computed private get authorsVersion(): Grapher { - return new Grapher({ - ...this.legacyConfigAsAuthored, - getGrapherInstance: undefined, - manager: undefined, - manuallyProvideData: true, - queryStr: "", - }) - } - - @computed get canonicalUrlIfIsChartView(): string | undefined { - if (!this.chartViewInfo) return undefined + // tab defined previously + // type defined in interface but not on Grapher - const { parentChartSlug, queryParamsForParentChart } = - this.chartViewInfo + @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { + if (!this.isLineChart) return false - const combinedQueryParams = { - ...queryParamsForParentChart, - ...this.changedParams, + let { minTime, maxTime } = this + + // if we have a time dimension but the timeline is hidden, + // we always want to use the authored `minTime` and `maxTime`, + // irrespective of the time range the user might have selected + // on the table tab + if (this.hasTimeDimensionButTimelineIsHidden) { + minTime = this.authorsVersion.minTime + maxTime = this.authorsVersion.maxTime } - return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( - combinedQueryParams - )}` - } + // This is the easy case: minTime and maxTime are the same, no need to do + // more fancy checks + if (minTime === maxTime) return true - @computed get isOnCanonicalUrl(): boolean { - if (!this.canonicalUrl) return false - return ( - getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname - ) + // We can have cases where minTime = Infinity and/or maxTime = -Infinity, + // but still only a single year is selected. + // To check for that we need to look at the times array. + const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues + const closestMinTime = findClosestTime(times, minTime ?? -Infinity) + const closestMaxTime = findClosestTime(times, maxTime ?? Infinity) + return closestMinTime !== undefined && closestMinTime === closestMaxTime } - @computed private get hasUserChangedTimeHandles(): boolean { - const authorsVersion = this.authorsVersion - return ( - this.minTime !== authorsVersion.minTime || - this.maxTime !== authorsVersion.maxTime - ) + // hasTimeline defined previously + + @action.bound resetHandleTimeBounds(): void { + this.startHandleTimeBound = this.timelineMinTime ?? -Infinity + this.endHandleTimeBound = this.timelineMaxTime ?? Infinity } - @computed private get hasUserChangedMapTimeHandle(): boolean { - return this.map.time !== this.authorsVersion.map.time + @computed get mapConfig(): MapConfig { + return this.map } + // endTime defined previously + // title defined previously + // #endregion + + // #region SlopeChartManager props + // canSelectMultipleEntities defined previously + // hasTimeline defined previously + // hideNoDataSection defined in interface but not on Grapher + // #endregion + + // Below are properties or functions that are not required by any interfaces but put here + // for completeness so that GrapherUrl.ts/grapherObjectToQueryParams can operate only on the state + mapGrapherTabToQueryParam(tab: GrapherTabName): string { + if (tab === GRAPHER_TAB_NAMES.Table) + return GRAPHER_TAB_QUERY_PARAMS.table + if (tab === GRAPHER_TAB_NAMES.WorldMap) + return GRAPHER_TAB_QUERY_PARAMS.map + + if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart + + return mapChartTypeNameToQueryParam(tab) + } @computed get timeParam(): string | undefined { const { timeColumn } = this.table const formatTime = (t: Time): string => @@ -2610,321 +2798,381 @@ export class Grapher return startTime === endTime ? startTime : `${startTime}..${endTime}` } - @computed get canAddEntities(): boolean { - return ( - this.hasChartTab && - this.canSelectMultipleEntities && - (this.isOnLineChartTab || - this.isOnSlopeChartTab || - this.isOnStackedAreaTab || - this.isOnStackedBarTab || - this.isOnDiscreteBarTab || - this.isOnStackedDiscreteBarTab) - ) - } - - @computed get showEntitySelectorAs(): GrapherWindowType { - if ( - this.frameBounds.width > 940 && - // don't use the panel if the grapher is embedded - ((!this.isInIFrame && !this.isEmbeddedInAnOwidPage) || - // unless we're in full-screen mode - this.isInFullScreenMode) - ) - return GrapherWindowType.panel - - return this.isSemiNarrow - ? GrapherWindowType.modal - : GrapherWindowType.drawer + @computed private get hasUserChangedMapTimeHandle(): boolean { + return this.map.time !== this.authorsVersion.map.time } - @computed get isEntitySelectorPanelActive(): boolean { + @computed private get hasUserChangedTimeHandles(): boolean { + const authorsVersion = this.authorsVersion return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - this.showEntitySelectorAs === GrapherWindowType.panel + this.minTime !== authorsVersion.minTime || + this.maxTime !== authorsVersion.maxTime ) } + @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentSelectedEntityNames = this.selection.selectedEntityNames + const originalSelectedEntityNames = + authoredConfig.selectedEntityNames ?? [] - @computed get showEntitySelectionToggle(): boolean { - return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - (this.showEntitySelectorAs === GrapherWindowType.modal || - this.showEntitySelectorAs === GrapherWindowType.drawer) + return isArrayDifferentFromReference( + currentSelectedEntityNames, + originalSelectedEntityNames ) } + @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentFocusedSeriesNames = this.focusArray.seriesNames + const originalFocusedSeriesNames = + authoredConfig.focusedSeriesNames ?? [] - @computed get isEntitySelectorModalOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.modal + return isArrayDifferentFromReference( + currentFocusedSeriesNames, + originalFocusedSeriesNames ) } - @computed get isEntitySelectorDrawerOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.drawer - ) + // Properties here are moved here so they can be used in tests + timelineController = new TimelineController(this) + @action.bound clearQueryParams(): void { + const { authorsVersion } = this + this.tab = authorsVersion.tab + this.xAxis.scaleType = authorsVersion.xAxis.scaleType + this.yAxis.scaleType = authorsVersion.yAxis.scaleType + this.stackMode = authorsVersion.stackMode + this.zoomToSelection = authorsVersion.zoomToSelection + this.compareEndPointsOnly = authorsVersion.compareEndPointsOnly + this.minTime = authorsVersion.minTime + this.maxTime = authorsVersion.maxTime + this.map.time = authorsVersion.map.time + this.map.projection = authorsVersion.map.projection + this.showSelectionOnlyInDataTable = + authorsVersion.showSelectionOnlyInDataTable + this.showNoDataArea = authorsVersion.showNoDataArea + this.clearSelection() + this.clearFocus() } - - // 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 - // we may still want to show those entities as available in a picker. We also do not want to do things like - // hide the Add Entity button as the user drags the timeline. - @computed private get numSelectableEntityNames(): number { - return this.selection.numAvailableEntityNames + @action.bound clearSelection(): void { + this.selection.clearSelection() + this.applyOriginalSelectionAsAuthored() + } + @action.bound clearFocus(): void { + this.focusArray.clear() + this.applyOriginalFocusAsAuthored() + } + @action.bound private applyOriginalFocusAsAuthored(): void { + if (this.focusedSeriesNames?.length) + this.focusArray.clearAllAndAdd(...this.focusedSeriesNames) } - /** - * todo: factor this out and make more RAII. - * - * Explorers create 1 Grapher instance, but as the user clicks around the Explorer loads other author created Graphers. - * But currently some Grapher features depend on knowing how the current state is different than the "authored state". - * So when an Explorer updates the grapher, it also needs to update this "original state". - */ - @action.bound setAuthoredVersion( - config: Partial - ): void { - this.legacyConfigAsAuthored = config + @action.bound applyOriginalSelectionAsAuthored(): void { + if (this.selectedEntityNames?.length) + this.selection.setSelectedEntities(this.selectedEntityNames) + } + @computed get availableEntities(): Entity[] { + return this.tableForSelection.availableEntities } +} - @action.bound updateAuthoredVersion( - config: Partial - ): void { - this.legacyConfigAsAuthored = { - ...this.legacyConfigAsAuthored, - ...config, - } +export interface GrapherProps { + grapherState: GrapherState +} + +@observer +export class Grapher extends React.Component { + @computed get grapherState(): GrapherState { + return this.props.grapherState } - constructor( - propsWithGrapherInstanceGetter: GrapherProgrammaticInterface = {} - ) { - super(propsWithGrapherInstanceGetter) + // #region Observable props not in any interface + + analytics = new GrapherAnalytics( + this.props.grapherState.initialOptions.env ?? "" + ) + seriesColorMap: SeriesColorMap = new Map() - const { getGrapherInstance, ...props } = propsWithGrapherInstanceGetter + // stored on Grapher so state is preserved when switching to full-screen mode - this.inputTable = props.table ?? BlankOwidTable(`initialGrapherTable`) + @observable + private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap + private hasLoggedGAViewEvent = false + @observable private hasBeenVisible = false + @observable private uncaughtError?: Error + @observable slideShow?: SlideShowController + @observable isShareMenuActive = false - if (props) this.setAuthoredVersion(props) + @computed get chartSeriesNames(): SeriesName[] { + if (!this.grapherState.isReady) return [] - // prefer the manager's selection over the config's selectedEntityNames - // if both are passed in and the manager's selection is not empty. - // this is necessary for the global entity selector to work correctly. - if (props.manager?.selection?.hasSelection) { - this.updateFromObject(omit(props, "selectedEntityNames")) - } else { - this.updateFromObject(props) + // collect series names from all chart instances when faceted + if (this.isFaceted) { + const facetChartInstance = new FacetChart({ + manager: this.grapherState, + }) + return uniq( + facetChartInstance.intermediateChartInstances.flatMap( + (chartInstance) => + chartInstance.series.map((series) => series.seriesName) + ) + ) } - this.populateFromQueryParams( - legacyToCurrentGrapherQueryParams(props.queryStr ?? "") + return this.grapherState.chartInstance.series.map( + (series) => series.seriesName ) - this.externalQueryParams = omit( - Url.fromQueryStr(props.queryStr ?? "").queryParams, - GRAPHER_QUERY_PARAM_KEYS + } + + /** + * Whether the chart is rendered in an Admin context (e.g. on owid.cloud). + */ + @computed get useAdminAPI(): boolean { + if (typeof window === "undefined") return false + return ( + window.admin !== undefined && + // Ensure that we're not accidentally matching on a DOM element with an ID of "admin" + typeof window.admin.isSuperuser === "boolean" ) + } - if (this.isEditor) { - this.ensureValidConfigWhenEditing() - } + @computed get hasData(): boolean { + return ( + this.grapherState.dimensions.length > 0 || + this.grapherState.newSlugs.length > 0 + ) + } + + // todo: do we need this? + @computed get originUrlWithProtocol(): string { + if (!this.grapherState.originUrl) return "" + let url = this.grapherState.originUrl + if (!url.startsWith("http")) url = `https://${url}` + return url + } + + @computed get shouldLinkToOwid(): boolean { + if ( + this.grapherState.isEmbeddedInAnOwidPage || + this.grapherState.isExportingToSvgOrPng || + !this.grapherState.isInIFrame + ) + return false - if (getGrapherInstance) getGrapherInstance(this) // todo: possibly replace with more idiomatic ref + return true } - toObject(): GrapherInterface { - const obj: GrapherInterface = objectWithPersistablesToObject( - this, - grapherKeysToSerialize - ) + @computed.struct private get variableIds(): number[] { + return uniq(this.grapherState.dimensions.map((d) => d.variableId)) + } - obj.selectedEntityNames = this.selection.selectedEntityNames - obj.focusedSeriesNames = this.focusArray.seriesNames + @computed get hasOWIDLogo(): boolean { + return ( + !this.grapherState.hideLogo && + (this.grapherState.logo === undefined || + this.grapherState.logo === "owid") + ) + } - deleteRuntimeAndUnchangedProps(obj, defaultObject) + // todo: did this name get botched in a merge? + @computed get hasFatalErrors(): boolean { + const { relatedQuestions = [] } = this.grapherState + return relatedQuestions.some( + (question) => !!getErrorMessageRelatedQuestionUrl(question) + ) + } - // always include the schema, even if it's the default - obj.$schema = this.$schema || latestGrapherConfigSchema + @computed get xScaleType(): ScaleType | undefined { + return this.grapherState.xAxis.scaleType + } - // JSON doesn't support Infinity, so we use strings instead. - if (obj.minTime) obj.minTime = minTimeToJSON(this.minTime) as any - if (obj.maxTime) obj.maxTime = maxTimeToJSON(this.maxTime) as any + @computed get sourcesLine(): string { + return this.grapherState.sourceDesc ?? this.defaultSourcesLine + } - if (obj.timelineMinTime) - obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any - if (obj.timelineMaxTime) - obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any + @computed get columnsWithSourcesCondensed(): CoreColumn[] { + const { yColumnSlugs } = this.grapherState - // todo: remove dimensions concept - // if (this.legacyConfigAsAuthored?.dimensions) - // obj.dimensions = this.legacyConfigAsAuthored.dimensions + const columnSlugs = [...yColumnSlugs] + columnSlugs.push(...this.getColumnSlugsForCondensedSources()) - return obj + return this.grapherState.inputTable + .getColumns(uniq(columnSlugs)) + .filter( + (column) => !!column.source.name || !isEmpty(column.def.origins) + ) } - @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void { - if (!obj) return - - updatePersistables(this, obj) + @computed private get defaultSourcesLine(): string { + const attributions = this.columnsWithSourcesCondensed.flatMap( + (column) => { + const { presentation = {} } = column.def + // if the variable metadata specifies an attribution on the + // variable level then this is preferred over assembling it from + // the source and origins + if ( + presentation.attribution !== undefined && + presentation.attribution !== "" + ) + return [presentation.attribution] + else { + const originFragments = getOriginAttributionFragments( + column.def.origins + ) + return [column.source.name, ...originFragments] + } + } + ) - // Regression fix: some legacies have this set to Null. Todo: clean DB. - if (obj.originUrl === null) this.originUrl = "" + const uniqueAttributions = uniq(compact(attributions)) - // update selection - if (obj.selectedEntityNames) - this.selection.setSelectedEntities(obj.selectedEntityNames) + if (uniqueAttributions.length > 3) + return `${uniqueAttributions[0]} and other sources` - // update focus - if (obj.focusedSeriesNames) - this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames) + return uniqueAttributions.join("; ") + } - // JSON doesn't support Infinity, so we use strings instead. - this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) - this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) + // Returns an object ready to be serialized to JSON + @computed get object(): GrapherInterface { + return this.grapherState.toObject() + } - this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity( - obj.timelineMinTime - ) - this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity( - obj.timelineMaxTime + @computed get hasYDimension(): boolean { + return this.grapherState.dimensions.some( + (d) => d.property === DimensionProperty.y ) - - // Todo: remove once we are more RAII. - if (obj?.dimensions?.length) - this.setDimensionsFromConfigs(obj.dimensions) } - @action.bound populateFromQueryParams(params: GrapherQueryParams): void { - // Set tab if specified - if (params.tab) { - const tab = this.mapQueryParamToGrapherTab(params.tab) - if (tab) this.setTab(tab) - else console.error("Unexpected tab: " + params.tab) - } - - // Set overlay if specified - const overlay = params.overlay - if (overlay) { - if (overlay === "sources") { - this.isSourcesModalOpen = true - } else if (overlay === "download") { - this.isDownloadModalOpen = true - } else { - console.error("Unexpected overlay: " + overlay) - } - } + @computed get defaultBounds(): Bounds { + return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT) + } - // Stack mode for bar and stacked area charts - this.stackMode = (params.stackMode ?? this.stackMode) as StackMode + @computed get cacheTag(): string { + return this.grapherState.version.toString() + } - this.zoomToSelection = - params.zoomToSelection === "true" ? true : this.zoomToSelection + // Filter data to what can be display on the map (across all times) + @computed get mappableData(): OwidVariableRow[] { + return this.grapherState.inputTable + .get(this.grapherState.mapColumnSlug) + .owidRows.filter((row) => isOnTheMap(row.entityName)) + } - // Axis scale mode - const xScaleType = params.xScale - if (xScaleType) { - if (xScaleType === ScaleType.linear || xScaleType === ScaleType.log) - this.xAxis.scaleType = xScaleType - else console.error("Unexpected xScale: " + xScaleType) - } + @computed get isMobile(): boolean { + return isMobile() + } - const yScaleType = params.yScale - if (yScaleType) { - if (yScaleType === ScaleType.linear || yScaleType === ScaleType.log) - this.yAxis.scaleType = yScaleType - else console.error("Unexpected xScale: " + yScaleType) - } + @computed get hideFullScreenButton(): boolean { + if (this.isInFullScreenMode) return false + // hide the full screen button if the full screen height + // is barely larger than the current chart height + const fullScreenHeight = this.grapherState.windowInnerHeight! + return fullScreenHeight < this.grapherState.frameBounds.height + 80 + } - const time = params.time - if (time !== undefined && time !== "") - this.setTimeFromTimeQueryParam(time) + @computed get sidePanelBounds(): Bounds | undefined { + if (!this.grapherState.isEntitySelectorPanelActive) return - const endpointsOnly = params.endpointsOnly - if (endpointsOnly !== undefined) - this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined + return new Bounds( + 0, // not in use; intentionally set to zero + 0, // not in use; intentionally set to zero + this.grapherState.frameBounds.width - + this.grapherState.captionedChartBounds.width, + this.grapherState.captionedChartBounds.height + ) + } + @computed get containerElement(): HTMLDivElement | undefined { + return this.grapherState.base.current || undefined + } - const region = params.region - if (region !== undefined) - this.map.projection = region as MapProjectionName + @computed get isFaceted(): boolean { + const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none + return this.grapherState.isOnChartTab && hasFacetStrategy + } - // selection - const selection = getSelectedEntityNamesParam( - Url.fromQueryParams(params) + // the header and footer don't rely on the base font size unless explicitly specified + @computed get useBaseFontSize(): boolean { + return ( + this.props.grapherState.initialOptions.baseFontSize !== undefined || + this.grapherState.isStatic ) - if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) - this.selection.setSelectedEntities(selection) - - // focus - const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus) - if (focusedSeriesNames) { - this.focusArray.clearAllAndAdd(...focusedSeriesNames) - } + } - // faceting - if (params.facet && params.facet in FacetStrategy) { - this.selectedFacetStrategy = params.facet as FacetStrategy - } - if (params.uniformYAxis === "0") { - this.yAxis.facetDomain = FacetAxisDomain.independent - } else if (params.uniformYAxis === "1") { - this.yAxis.facetDomain = FacetAxisDomain.shared - } + @computed get hasRelatedQuestion(): boolean { + if ( + this.grapherState.hideRelatedQuestion || + !this.grapherState.relatedQuestions || + !this.grapherState.relatedQuestions.length + ) + return false + const question = this.grapherState.relatedQuestions[0] + return !!question && !!question.text && !!question.url + } - // only relevant for the table - if (params.showSelectionOnlyInTable) { - this.showSelectionOnlyInDataTable = - params.showSelectionOnlyInTable === "1" ? true : undefined - } + @computed get isRelatedQuestionTargetDifferentFromCurrentPage(): boolean { + // comparing paths rather than full URLs for this to work as + // expected on local and staging where the origin (e.g. + // hans.owid.cloud) doesn't match the production origin that has + // been entered in the related question URL field: + // "ourworldindata.org" and yet should still yield a match. + // - Note that this won't work on production previews (where the + // path is /admin/posts/preview/ID) + const { relatedQuestions = [] } = this.grapherState + const { hasRelatedQuestion } = this + const relatedQuestion = relatedQuestions[0] + return ( + hasRelatedQuestion && + !!relatedQuestion && + getWindowUrl().pathname !== + Url.fromURL(relatedQuestion.url).pathname + ) + } - if (params.showNoDataArea) { - this.showNoDataArea = params.showNoDataArea === "1" - } + @computed get showRelatedQuestion(): boolean { + return ( + !!this.grapherState.relatedQuestions && + !!this.hasRelatedQuestion && + !!this.isRelatedQuestionTargetDifferentFromCurrentPage + ) } - @action.bound private setTimeFromTimeQueryParam(time: string): void { - this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( - (time) => findClosestTime(this.times, time) ?? time - ) as TimeBounds + @computed get isOnCanonicalUrl(): boolean { + if (!this.grapherState.canonicalUrl) return false + return ( + getWindowUrl().pathname === + Url.fromURL(this.grapherState.canonicalUrl).pathname + ) } - // Convenience method for debugging - windowQueryParams(str = location.search): QueryParams { - return strToQueryParams(str) + @computed get showEntitySelectionToggle(): boolean { + return ( + !this.grapherState.hideEntityControls && + this.grapherState.canChangeAddOrHighlightEntities && + this.grapherState.isOnChartTab && + (this.grapherState.showEntitySelectorAs === + GrapherWindowType.modal || + this.grapherState.showEntitySelectorAs === + GrapherWindowType.drawer) + ) } - // Exclusively used for the performance.measurement API, so that DevTools can show some context - private createPerformanceMeasurement( - name: string, - startMark: number + @action.bound updateAuthoredVersion( + config: Partial ): void { - const endMark = performance.now() - const detail = { - devtools: { - track: "Grapher", - properties: [ - // might be missing for charts within explorers or mdims - ["slug", this.slug ?? "missing-slug"], - ["chartTypes", this.validChartTypes], - ["tab", this.tab], - ], - }, + this.grapherState.legacyConfigAsAuthored = { + ...this.grapherState.legacyConfigAsAuthored, + ...config, } + } - try { - performance.measure(name, { - start: startMark, - end: endMark, - detail, - }) - } catch { - // In old browsers, the above may throw an error - just ignore it - } + constructor(props: { grapherState: GrapherState }) { + super(props) + } + + // Convenience method for debugging + windowQueryParams(str = location.search): QueryParams { + return strToQueryParams(str) } + + // Exclusively used for the performance.measurement API, so that DevTools can show some context @action.bound private _setInputTable( json: MultipleOwidVariableDataDimensionsMap, legacyConfig: Partial @@ -2945,24 +3193,24 @@ export class Grapher const dimensions = legacyConfig.dimensions ? computeActualDimensions(legacyConfig.dimensions) : [] - this.createPerformanceMeasurement( + this.grapherState.createPerformanceMeasurement( "legacyToOwidTableAndDimensions", startMark ) - this.inputTable = tableWithColors + this.grapherState.inputTable = tableWithColors // We need to reset the dimensions because some of them may have changed slugs in the legacy // transformation (can happen when columns use targetTime) - this.setDimensionsFromConfigs(dimensions) + this.grapherState.setDimensionsFromConfigs(dimensions) this.appendNewEntitySelectionOptions() - if (this.manager?.selection?.hasSelection) { + if (this.grapherState.manager?.selection?.hasSelection) { // Selection is managed externally, do nothing. - } else if (this.selection.hasSelection) { + } else if (this.grapherState.selection.hasSelection) { // User has changed the selection, use theris - } else this.applyOriginalSelectionAsAuthored() + } else this.grapherState.applyOriginalSelectionAsAuthored() } @action rebuildInputOwidTable(): void { @@ -2970,112 +3218,29 @@ export class Grapher if (!this.legacyVariableDataJson) return this._setInputTable( this.legacyVariableDataJson, - this.legacyConfigAsAuthored + this.grapherState.legacyConfigAsAuthored ) } @action.bound appendNewEntitySelectionOptions(): void { - const { selection } = this + const { selection } = this.grapherState const currentEntities = selection.availableEntityNameSet - const missingEntities = this.availableEntities.filter( + const missingEntities = this.grapherState.availableEntities.filter( (entity) => !currentEntities.has(entity.entityName) ) selection.addAvailableEntityNames(missingEntities) } - @action.bound private applyOriginalSelectionAsAuthored(): void { - if (this.selectedEntityNames?.length) - this.selection.setSelectedEntities(this.selectedEntityNames) - } - - set startHandleTimeBound(newValue: TimeBound) { - if (this.isSingleTimeSelectionActive) - this.timelineHandleTimeBounds = [newValue, newValue] - else - this.timelineHandleTimeBounds = [ - newValue, - this.timelineHandleTimeBounds[1], - ] - } - - set endHandleTimeBound(newValue: TimeBound) { - if (this.isSingleTimeSelectionActive) - this.timelineHandleTimeBounds = [newValue, newValue] - else - this.timelineHandleTimeBounds = [ - this.timelineHandleTimeBounds[0], - newValue, - ] - } - // Keeps a running cache of series colors at the Grapher level. - disposers: (() => void)[] = [] - @bind dispose(): void { - this.disposers.forEach((dispose) => dispose()) - } - - @action.bound setTab(newTab: GrapherTabName): void { - if (newTab === GRAPHER_TAB_NAMES.Table) { - this.tab = GRAPHER_TAB_OPTIONS.table - this.chartTab = undefined - } else if (newTab === GRAPHER_TAB_NAMES.WorldMap) { - this.tab = GRAPHER_TAB_OPTIONS.map - this.chartTab = undefined - } else { - this.tab = GRAPHER_TAB_OPTIONS.chart - this.chartTab = newTab - } - } - - @action.bound onTabChange( - oldTab: GrapherTabName, - newTab: GrapherTabName - ): void { - // if switching from a line to a slope chart and the handles are - // on the same time, then automatically adjust the handles so that - // the slope chart view is meaningful - if ( - oldTab === GRAPHER_TAB_NAMES.LineChart && - newTab === GRAPHER_TAB_NAMES.SlopeChart && - this.areHandlesOnSameTime - ) { - if (this.startHandleTimeBound !== -Infinity) { - this.startHandleTimeBound = -Infinity - } else { - this.endHandleTimeBound = Infinity - } - } - } - - // todo: can we remove this? - // I believe these states can only occur during editing. - @action.bound private ensureValidConfigWhenEditing(): void { - const disposers = [ - autorun(() => { - if (!this.availableTabs.includes(this.activeTab)) - runInAction(() => this.setTab(this.availableTabs[0])) - }), - autorun(() => { - const validDimensions = this.validDimensions - if (!isEqual(this.dimensions, validDimensions)) - this.dimensions = validDimensions - }), - ] - this.disposers.push(...disposers) - } - set timelineHandleTimeBounds(value: TimeBounds) { - if (this.isOnMapTab) { - this.map.time = value[1] - } else { - this.minTime = value[0] - this.maxTime = value[1] - } + this.grapherState.disposers.forEach((dispose) => dispose()) } @action.bound addDimension(config: OwidChartDimensionInterface): void { - this.dimensions.push(new ChartDimension(config, this)) + this.grapherState.dimensions.push( + new ChartDimension(config, this.grapherState) + ) } @action.bound setDimensionsForProperty( @@ -3083,36 +3248,28 @@ export class Grapher newConfigs: OwidChartDimensionInterface[] ): void { let newDimensions: ChartDimension[] = [] - this.dimensionSlots.forEach((slot) => { + this.grapherState.dimensionSlots.forEach((slot) => { if (slot.property === property) newDimensions = newDimensions.concat( - newConfigs.map((config) => new ChartDimension(config, this)) + newConfigs.map( + (config) => + new ChartDimension(config, this.grapherState) + ) ) else newDimensions = newDimensions.concat(slot.dimensions) }) - this.dimensions = newDimensions - } - - @action.bound setDimensionsFromConfigs( - configs: OwidChartDimensionInterface[] - ): void { - this.dimensions = configs.map( - (config) => new ChartDimension(config, this) - ) + this.grapherState.dimensions = newDimensions } getColumnForProperty(property: DimensionProperty): CoreColumn | undefined { - return this.dimensions.find((dim) => dim.property === property)?.column - } - - getSlugForProperty(property: DimensionProperty): string | undefined { - return this.dimensions.find((dim) => dim.property === property) - ?.columnSlug + return this.grapherState.dimensions.find( + (dim) => dim.property === property + )?.column } getColumnSlugsForCondensedSources(): string[] { const { xColumnSlug, sizeColumnSlug, colorColumnSlug, isMarimekko } = - this + this.grapherState const columnSlugs: string[] = [] // exclude "Countries Continent" if it's used as the color dimension in a scatter plot, slope chart etc. @@ -3123,7 +3280,7 @@ export class Grapher columnSlugs.push(colorColumnSlug) if (xColumnSlug !== undefined) { - const xColumn = this.inputTable.get(xColumnSlug) + const xColumn = this.grapherState.inputTable.get(xColumnSlug) .def as OwidColumnDef // exclude population variable if it's used as the x dimension in a marimekko if ( @@ -3135,7 +3292,7 @@ export class Grapher // exclude population variable if it's used as the size dimension in a scatter plot if (sizeColumnSlug !== undefined) { - const sizeColumn = this.inputTable.get(sizeColumnSlug) + const sizeColumn = this.grapherState.inputTable.get(sizeColumnSlug) .def as OwidColumnDef if (!isPopulationVariableETLPath(sizeColumn?.catalogPath ?? "")) columnSlugs.push(sizeColumnSlug) @@ -3145,48 +3302,22 @@ export class Grapher // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? set xOverrideTime(value: number | undefined) { - this.xDimension!.targetYear = value + this.grapherState.xDimension!.targetYear = value } set staticFormat(format: GrapherStaticFormat) { - this._staticFormat = format - } - - getStaticBounds(format: GrapherStaticFormat): Bounds { - switch (format) { - case GrapherStaticFormat.landscape: - return this.defaultBounds - case GrapherStaticFormat.square: - return new Bounds( - 0, - 0, - GRAPHER_SQUARE_SIZE, - GRAPHER_SQUARE_SIZE - ) - default: - return this.defaultBounds - } - } - - generateStaticSvg(): string { - const _isExportingToSvgOrPng = this.isExportingToSvgOrPng - this.isExportingToSvgOrPng = true - const staticSvg = ReactDOMServer.renderToStaticMarkup( - - ) - this.isExportingToSvgOrPng = _isExportingToSvgOrPng - return staticSvg + this.grapherState._staticFormat = format } get staticSVG(): string { - return this.generateStaticSvg() + return this.grapherState.generateStaticSvg() } static renderGrapherIntoContainer( config: GrapherProgrammaticInterface, containerNode: Element - ): React.RefObject { - const grapherInstanceRef = React.createRef() + ): void { + // const grapherInstanceRef = React.createRef() let ErrorBoundary = React.Fragment as React.ComponentType // use React.Fragment as a sort of default error boundary if Bugsnag is not available if (Bugsnag && (Bugsnag as any)._client) { @@ -3208,13 +3339,18 @@ export class Grapher // see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent if ((entry.target as HTMLElement).offsetParent === null) return - const props: GrapherProgrammaticInterface = { + const grapherState = new GrapherState({ ...config, bounds: Bounds.fromRect(entry.contentRect), - } + }) + ReactDOM.render( - + , containerNode ) @@ -3239,8 +3375,6 @@ export class Grapher ) Bugsnag?.notify("ResizeObserver not available") } - - return grapherInstanceRef } static renderSingleGrapherOnGrapherPage( @@ -3273,17 +3407,19 @@ export class Grapher } private get commandPalette(): React.ReactElement | null { - return this.props.enableKeyboardShortcuts ? ( + return this.props.grapherState.enableKeyboardShortcuts ? ( ) : null } @action.bound private toggleTabCommand(): void { - this.setTab(next(this.availableTabs, this.activeTab)) + this.grapherState.setTab( + next(this.grapherState.availableTabs, this.grapherState.activeTab) + ) } @action.bound private togglePlayingCommand(): void { - void this.timelineController.togglePlay() + void this.grapherState.timelineController.togglePlay() } private get keyboardShortcuts(): Command[] { @@ -3310,14 +3446,14 @@ export class Grapher { combo: "a", fn: (): void => { - if (this.selection.hasSelection) { - this.selection.clearSelection() - this.focusArray.clear() + if (this.grapherState.selection.hasSelection) { + this.grapherState.selection.clearSelection() + this.grapherState.focusArray.clear() } else { - this.selection.selectAll() + this.grapherState.selection.selectAll() } }, - title: this.selection.hasSelection + title: this.grapherState.selection.hasSelection ? `Select None` : `Select All`, category: "Selection", @@ -3325,7 +3461,8 @@ export class Grapher { combo: "f", fn: (): void => { - this.hideFacetControl = !this.hideFacetControl + this.grapherState.hideFacetControl = + !this.grapherState.hideFacetControl }, title: `Toggle Faceting`, category: "Chart", @@ -3333,7 +3470,7 @@ export class Grapher { combo: "p", fn: (): void => this.togglePlayingCommand(), - title: this.isPlaying ? `Pause` : `Play`, + title: this.grapherState.isPlaying ? `Pause` : `Play`, category: "Timeline", }, { @@ -3351,7 +3488,8 @@ export class Grapher { combo: "s", fn: (): void => { - this.isSourcesModalOpen = !this.isSourcesModalOpen + this.grapherState.isSourcesModalOpen = + !this.grapherState.isSourcesModalOpen }, title: `Toggle sources modal`, category: "Chart", @@ -3359,7 +3497,8 @@ export class Grapher { combo: "d", fn: (): void => { - this.isDownloadModalOpen = !this.isDownloadModalOpen + this.grapherState.isDownloadModalOpen = + !this.grapherState.isDownloadModalOpen }, title: "Toggle download modal", category: "Chart", @@ -3376,7 +3515,7 @@ export class Grapher }, { combo: "shift+o", - fn: (): void => this.clearQueryParams(), + fn: (): void => this.grapherState.clearQueryParams(), title: "Reset to original", category: "Navigation", }, @@ -3403,29 +3542,34 @@ export class Grapher @action.bound private toggleTimelineCommand(): void { // Todo: add tests for this - this.setTimeFromTimeQueryParam( - next(["latest", "earliest", ".."], this.timeParam!) + this.grapherState.setTimeFromTimeQueryParam( + next(["latest", "earliest", ".."], this.grapherState.timeParam!) ) } @action.bound private toggleYScaleTypeCommand(): void { - this.yAxis.scaleType = next( + this.grapherState.yAxis.scaleType = next( [ScaleType.linear, ScaleType.log], - this.yAxis.scaleType + this.grapherState.yAxis.scaleType ) } set facetStrategy(facet: FacetStrategy) { - this.selectedFacetStrategy = facet + this.grapherState.selectedFacetStrategy = facet } @action.bound randomSelection(num: number): void { // Continent, Population, GDP PC, GDP, PopDens, UN, Language, etc. this.clearErrors() - const currentSelection = this.selection.selectedEntityNames.length + const currentSelection = + this.grapherState.selection.selectedEntityNames.length const newNum = num ? num : currentSelection ? currentSelection * 2 : 10 - this.selection.setSelectedEntities( - sampleFrom(this.selection.availableEntityNames, newNum, Date.now()) + this.grapherState.selection.setSelectedEntities( + sampleFrom( + this.grapherState.selection.availableEntityNames, + newNum, + Date.now() + ) ) } @@ -3440,7 +3584,7 @@ export class Grapher // dismiss the share menu this.isShareMenuActive = false - this._isInFullScreenMode = newValue + this.grapherState._isInFullScreenMode = newValue } @action.bound toggleFullScreenMode(): void { @@ -3449,11 +3593,11 @@ export class Grapher @action.bound dismissFullScreen(): void { // if a modal is open, dismiss it instead of exiting full-screen mode - if (this.isModalOpen || this.isShareMenuActive) { - this.isEntitySelectorModalOrDrawerOpen = false - this.isSourcesModalOpen = false - this.isEmbedModalOpen = false - this.isDownloadModalOpen = false + if (this.grapherState.isModalOpen || this.isShareMenuActive) { + this.grapherState.isEntitySelectorModalOrDrawerOpen = false + this.grapherState.isSourcesModalOpen = false + this.grapherState.isEmbedModalOpen = false + this.grapherState.isDownloadModalOpen = false this.isShareMenuActive = false } else { this.isInFullScreenMode = false @@ -3503,33 +3647,33 @@ export class Grapher private renderGrapherComponent(): React.ReactElement { const containerClasses = classnames({ GrapherComponent: true, - GrapherPortraitClass: this.isPortrait, - isStatic: this.isStatic, - isExportingToSvgOrPng: this.isExportingToSvgOrPng, - GrapherComponentNarrow: this.isNarrow, - GrapherComponentSemiNarrow: this.isSemiNarrow, - GrapherComponentSmall: this.isSmall, - GrapherComponentMedium: this.isMedium, + GrapherPortraitClass: this.grapherState.isPortrait, + isStatic: this.grapherState.isStatic, + isExportingToSvgOrPng: this.grapherState.isExportingToSvgOrPng, + GrapherComponentNarrow: this.grapherState.isNarrow, + GrapherComponentSemiNarrow: this.grapherState.isSemiNarrow, + GrapherComponentSmall: this.grapherState.isSmall, + GrapherComponentMedium: this.grapherState.isMedium, }) - const activeBounds = this.renderToStatic - ? this.staticBounds - : this.frameBounds + const activeBounds = this.grapherState.renderToStatic + ? this.grapherState.staticBounds + : this.grapherState.frameBounds const containerStyle = { width: activeBounds.width, height: activeBounds.height, - fontSize: this.isExportingToSvgOrPng + fontSize: this.grapherState.isExportingToSvgOrPng ? 18 - : Math.min(16, this.fontSize), // cap font size at 16px + : Math.min(16, this.grapherState.fontSize), // cap font size at 16px } return (
{this.commandPalette} {this.uncaughtError ? this.renderError() : this.renderReady()} @@ -3540,13 +3684,16 @@ export class Grapher render(): React.ReactElement | undefined { // TODO how to handle errors in exports? // TODO remove this? should have a simple toStaticSVG for exporting - if (this.isExportingToSvgOrPng) return + if (this.grapherState.isExportingToSvgOrPng) + return if (this.isInFullScreenMode) { return ( {this.renderGrapherComponent()} @@ -3559,55 +3706,68 @@ export class Grapher private renderReady(): React.ReactElement | null { if (!this.hasBeenVisible) return null - if (this.renderToStatic) { - return + if (this.grapherState.renderToStatic) { + return } return ( <> {/* captioned chart and entity selector */}
- + {this.sidePanelBounds && ( - + )}
{/* modals */} - {this.isSourcesModalOpen && } - {this.isDownloadModalOpen && } - {this.isEmbedModalOpen && } - {this.isEntitySelectorModalOpen && ( - + {this.grapherState.isSourcesModalOpen && ( + + )} + {this.grapherState.isDownloadModalOpen && ( + + )} + {this.grapherState.isEmbedModalOpen && ( + + )} + {this.grapherState.isEntitySelectorModalOpen && ( + )} {/* entity selector in a slide-in drawer */} { - this.isEntitySelectorModalOrDrawerOpen = - !this.isEntitySelectorModalOrDrawerOpen + this.grapherState.isEntitySelectorModalOrDrawerOpen = + !this.grapherState.isEntitySelectorModalOrDrawerOpen }} > - + {/* tooltip: either pin to the bottom or render into the chart area */} - {this.shouldPinTooltipToBottom ? ( + {this.grapherState.shouldPinTooltipToBottom ? ( ) : ( )} @@ -3623,14 +3783,19 @@ export class Grapher if (entry.isIntersecting) { this.hasBeenVisible = true - if (this.slug && !this.hasLoggedGAViewEvent) { - this.analytics.logGrapherView(this.slug) + if ( + this.grapherState.slug && + !this.hasLoggedGAViewEvent + ) { + this.analytics.logGrapherView( + this.grapherState.slug + ) this.hasLoggedGAViewEvent = true } } // dismiss tooltip when less than 2/3 of the chart is visible - const tooltip = this.tooltip?.get() + const tooltip = this.grapherState.tooltip?.get() const isNotVisible = !entry.isIntersecting const isPartiallyVisible = entry.isIntersecting && @@ -3643,7 +3808,7 @@ export class Grapher { threshold: [0, 0.66] } ) observer.observe(this.containerElement!) - this.disposers.push(() => observer.disconnect()) + this.grapherState.disposers.push(() => observer.disconnect()) } else { // IntersectionObserver not available; we may be in a Node environment, just render this.hasBeenVisible = true @@ -3651,25 +3816,12 @@ export class Grapher } set baseFontSize(val: number) { - this._baseFontSize = val - } - - private computeBaseFontSizeFromHeight(bounds: Bounds): number { - const squareBounds = this.getStaticBounds(GrapherStaticFormat.square) - const factor = squareBounds.height / 21 - return Math.max(10, bounds.height / factor) - } - - private computeBaseFontSizeFromWidth(bounds: Bounds): number { - if (bounds.width <= 400) return 14 - else if (bounds.width < 1080) return 16 - else if (bounds.width >= 1080) return 18 - else return 16 + this.grapherState._baseFontSize = val } @action.bound private setBaseFontSize(): void { - this.baseFontSize = this.computeBaseFontSizeFromWidth( - this.captionedChartBounds + this.baseFontSize = this.grapherState.computeBaseFontSizeFromWidth( + this.grapherState.captionedChartBounds ) } @@ -3679,21 +3831,21 @@ export class Grapher // There is a surprisingly considerable performance overhead to updating the url // while animating, so we debounce to allow e.g. smoother timelines const pushParams = (): void => - setWindowQueryStr(queryParamsToStr(this.changedParams)) + setWindowQueryStr(queryParamsToStr(this.grapherState.changedParams)) const debouncedPushParams = debounce(pushParams, 100) reaction( - () => this.changedParams, + () => this.grapherState.changedParams, () => (this.debounceMode ? debouncedPushParams() : pushParams()) ) - autorun(() => (document.title = this.currentTitle)) + autorun(() => (document.title = this.grapherState.currentTitle)) } @action.bound private setUpWindowResizeEventHandler(): void { const updateWindowDimensions = (): void => { - this.windowInnerWidth = window.innerWidth - this.windowInnerHeight = window.innerHeight + this.grapherState.windowInnerWidth = window.innerWidth + this.grapherState.windowInnerHeight = window.innerHeight } const onResize = debounce(updateWindowDimensions, 400, { leading: true, @@ -3702,7 +3854,7 @@ export class Grapher if (typeof window !== "undefined") { updateWindowDimensions() window.addEventListener("resize", onResize) - this.disposers.push(() => { + this.grapherState.disposers.push(() => { window.removeEventListener("resize", onResize) }) } @@ -3715,11 +3867,11 @@ export class Grapher exposeInstanceOnWindow(this, "grapher") // Emit a custom event when the grapher is ready // We can use this in global scripts that depend on the grapher e.g. the site-screenshots tool - this.disposers.push( + this.grapherState.disposers.push( reaction( - () => this.isReady, + () => this.grapherState.isReady, () => { - if (this.isReady) { + if (this.grapherState.isReady) { document.dispatchEvent( new CustomEvent(GRAPHER_LOADED_EVENT_NAME, { detail: { grapher: this }, @@ -3730,11 +3882,12 @@ export class Grapher ), reaction( () => this.facetStrategy, - () => this.focusArray.clear() + () => this.grapherState.focusArray.clear() ) ) - if (this.props.bindUrlToWindow) this.bindToWindow() - if (this.props.enableKeyboardShortcuts) this.bindKeyboardShortcuts() + if (this.grapherState.bindUrlToWindow) this.bindToWindow() + if (this.grapherState.enableKeyboardShortcuts) + this.bindKeyboardShortcuts() } private _shortcutsBound = false @@ -3775,106 +3928,27 @@ export class Grapher this.analytics.logGrapherViewError(error) } - @action.bound clearSelection(): void { - this.selection.clearSelection() - this.applyOriginalSelectionAsAuthored() - } - - @action.bound clearFocus(): void { - this.focusArray.clear() - this.applyOriginalFocusAsAuthored() - } - - @action.bound clearQueryParams(): void { - const { authorsVersion } = this - this.tab = authorsVersion.tab - this.xAxis.scaleType = authorsVersion.xAxis.scaleType - this.yAxis.scaleType = authorsVersion.yAxis.scaleType - this.stackMode = authorsVersion.stackMode - this.zoomToSelection = authorsVersion.zoomToSelection - this.compareEndPointsOnly = authorsVersion.compareEndPointsOnly - this.minTime = authorsVersion.minTime - this.maxTime = authorsVersion.maxTime - this.map.time = authorsVersion.map.time - this.map.projection = authorsVersion.map.projection - this.showSelectionOnlyInDataTable = - authorsVersion.showSelectionOnlyInDataTable - this.showNoDataArea = authorsVersion.showNoDataArea - this.clearSelection() - this.clearFocus() - } - // Todo: come up with a more general pattern? // The idea here is to reset the Grapher to a blank slate, so that if you updateFromObject and the object contains some blanks, those blanks // won't overwrite defaults (like type == LineChart). RAII would probably be better, but this works for now. @action.bound reset(): void { - const grapher = new Grapher() + const grapherState = new GrapherState({}) for (const key of grapherKeysToSerialize) { // @ts-expect-error grapherKeysToSerialize is not properly typed - this[key] = grapher[key] + this.grapherState[key] = grapherState[key] } - this.ySlugs = grapher.ySlugs - this.xSlug = grapher.xSlug - this.colorSlug = grapher.colorSlug - this.sizeSlug = grapher.sizeSlug + this.grapherState.ySlugs = grapherState.ySlugs + this.grapherState.xSlug = grapherState.xSlug + this.grapherState.colorSlug = grapherState.colorSlug + this.grapherState.sizeSlug = grapherState.sizeSlug - this.selection.clearSelection() - this.focusArray.clear() + this.grapherState.selection.clearSelection() + this.grapherState.focusArray.clear() } debounceMode = false - private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { - const { - chartType: defaultChartType, - validChartTypeSet, - hasMapTab, - } = this - - if (tab === GRAPHER_TAB_QUERY_PARAMS.table) { - return GRAPHER_TAB_NAMES.Table - } - if (tab === GRAPHER_TAB_QUERY_PARAMS.map) { - return GRAPHER_TAB_NAMES.WorldMap - } - - if (tab === GRAPHER_TAB_QUERY_PARAMS.chart) { - if (defaultChartType) { - return defaultChartType - } else if (hasMapTab) { - return GRAPHER_TAB_NAMES.WorldMap - } else { - return GRAPHER_TAB_NAMES.Table - } - } - - const chartTypeName = mapQueryParamToChartTypeName(tab) - - if (!chartTypeName) return undefined - - if (validChartTypeSet.has(chartTypeName)) { - return chartTypeName - } else if (defaultChartType) { - return defaultChartType - } else if (hasMapTab) { - return GRAPHER_TAB_NAMES.WorldMap - } else { - return GRAPHER_TAB_NAMES.Table - } - } - - mapGrapherTabToQueryParam(tab: GrapherTabName): string { - if (tab === GRAPHER_TAB_NAMES.Table) - return GRAPHER_TAB_QUERY_PARAMS.table - if (tab === GRAPHER_TAB_NAMES.WorldMap) - return GRAPHER_TAB_QUERY_PARAMS.map - - if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart - - return mapChartTypeNameToQueryParam(tab) - } - // todo: restore this behavior?? onStartPlayOrDrag(): void { this.debounceMode = true @@ -3885,7 +3959,7 @@ export class Grapher } formatTime(value: Time): string { - const timeColumn = this.table.timeColumn + const timeColumn = this.grapherState.table.timeColumn return isMobile() ? timeColumn.formatValueForMobile(value) : timeColumn.formatValue(value) @@ -3893,7 +3967,7 @@ export class Grapher } const defaultObject = objectWithPersistablesToObject( - new Grapher(), + new GrapherState({}), grapherKeysToSerialize ) diff --git a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts index de8d7f2ce19..0771aa60470 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts @@ -9,7 +9,11 @@ import { generateSelectedEntityNamesParam, } from "./EntityUrlBuilder.js" import { match } from "ts-pattern" -import { Grapher } from "./Grapher.js" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "./Grapher.js" // This function converts a (potentially partial) GrapherInterface to the query params this translates to. // This is helpful for when we have a patch config to a parent chart, and we want to know which query params we need to get the parent chart as close as possible to the patched child chart. @@ -78,12 +82,12 @@ export const grapherConfigToQueryParams = ( } export const grapherObjectToQueryParams = ( - grapher: Grapher + grapher: GrapherState ): GrapherQueryParams => { const params: GrapherQueryParams = { tab: grapher.mapGrapherTabToQueryParam(grapher.activeTab), - xScale: grapher.xAxis.scaleType, - yScale: grapher.yAxis.scaleType, + xScale: grapher.xAxis?.scaleType, + yScale: grapher.yAxis?.scaleType, stackMode: grapher.stackMode, zoomToSelection: grapher.zoomToSelection ? "true" : undefined, endpointsOnly: grapher.compareEndPointsOnly ? "1" : "0", diff --git a/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx b/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx index 6a4c8bb65b1..6d53bc759cc 100755 --- a/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx +++ b/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx @@ -5,14 +5,18 @@ import { SynthesizeGDPTable, SampleColumnSlugs, } from "@ourworldindata/core-table" -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "../core/Grapher" import { MapChart } from "../mapCharts/MapChart" import { legacyMapGrapher } from "../mapCharts/MapChart.sample" import { GRAPHER_CHART_TYPES } from "@ourworldindata/types" describe("grapher and map charts", () => { describe("map time tolerance plus query string works with a map chart", () => { - const grapher = new Grapher(legacyMapGrapher) + const grapher = new GrapherState(legacyMapGrapher) expect(grapher.mapColumnSlug).toBe("3512") expect(grapher.inputTable.minTime).toBe(2000) expect(grapher.inputTable.maxTime).toBe(2010) @@ -26,7 +30,7 @@ describe("grapher and map charts", () => { }) it("can change time and see more points", () => { - const manager = new Grapher(legacyMapGrapher) + const manager = new GrapherState(legacyMapGrapher) const chart = new MapChart({ manager }) expect(Object.keys(chart.series).length).toEqual(1) @@ -49,7 +53,7 @@ const basicGrapherConfig: GrapherProgrammaticInterface = { } describe("grapher and discrete bar charts", () => { - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [GRAPHER_CHART_TYPES.DiscreteBar], ...basicGrapherConfig, }) diff --git a/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts b/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts index 885b71afdf8..5b8d705e924 100644 --- a/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts +++ b/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts @@ -1,5 +1,5 @@ import { DimensionProperty } from "@ourworldindata/utils" -import { Grapher } from "../core/Grapher" +import { Grapher, GrapherState } from "../core/Grapher" import { GRAPHER_TAB_OPTIONS, GrapherInterface } from "@ourworldindata/types" import { TestMetadata, @@ -9,7 +9,7 @@ import { export const childMortalityGrapher = ( props: Partial = {} -): Grapher => { +): GrapherState => { const childMortalityId = 104402 const childMortalityMetadata: TestMetadata = { id: childMortalityId, @@ -36,7 +36,7 @@ export const childMortalityGrapher = ( property: DimensionProperty.y, }, ] - return new Grapher({ + return new GrapherState({ hasMapTab: true, tab: GRAPHER_TAB_OPTIONS.map, dimensions, @@ -52,7 +52,7 @@ export const childMortalityGrapher = ( export const GrapherWithIncompleteData = ( props: Partial = {} -): Grapher => { +): GrapherState => { const indicatorId = 3512 const metadata = { id: indicatorId, shortUnit: "%" } const data = [ @@ -84,7 +84,7 @@ export const GrapherWithIncompleteData = ( }, }, ] - return new Grapher({ + return new GrapherState({ tab: GRAPHER_TAB_OPTIONS.table, dimensions, ...props, @@ -94,7 +94,7 @@ export const GrapherWithIncompleteData = ( export const GrapherWithAggregates = ( props: Partial = {} -): Grapher => { +): GrapherState => { const childMortalityId = 104402 const childMortalityMetadata: TestMetadata = { id: childMortalityId, @@ -124,7 +124,7 @@ export const GrapherWithAggregates = ( property: DimensionProperty.y, }, ] - return new Grapher({ + return new GrapherState({ tab: GRAPHER_TAB_OPTIONS.table, dimensions, ...props, @@ -136,7 +136,7 @@ export const GrapherWithAggregates = ( export const GrapherWithMultipleVariablesAndMultipleYears = ( props: Partial = {} -): Grapher => { +): GrapherState => { const abovePovertyLineId = 514050 const belowPovertyLineId = 472265 @@ -168,7 +168,7 @@ export const GrapherWithMultipleVariablesAndMultipleYears = ( ], } - return new Grapher({ + return new GrapherState({ tab: GRAPHER_TAB_OPTIONS.table, dimensions, ...props, diff --git a/packages/@ourworldindata/grapher/src/index.ts b/packages/@ourworldindata/grapher/src/index.ts index ef77a13b1ed..d205b9cb9b8 100644 --- a/packages/@ourworldindata/grapher/src/index.ts +++ b/packages/@ourworldindata/grapher/src/index.ts @@ -7,7 +7,10 @@ export { type ColorScaleBin, } from "./color/ColorScaleBin" export { ChartDimension } from "./chart/ChartDimension" -export { FetchingGrapher } from "./core/FetchingGrapher" +export { + FetchingGrapher, + fetchInputTableForConfig, +} from "./core/FetchingGrapher" export { GRAPHER_EMBEDDED_FIGURE_ATTR, GRAPHER_EMBEDDED_FIGURE_CONFIG_ATTR, @@ -55,6 +58,7 @@ export { export { GlobalEntitySelector } from "./controls/globalEntitySelector/GlobalEntitySelector" export { Grapher, + GrapherState, type GrapherProgrammaticInterface, type GrapherManager, getErrorMessageRelatedQuestionUrl, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index 6a3c29eb8c7..d4d9795077c 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -244,6 +244,7 @@ export class MapChart id: feature.id, series: series || { value: "No data" }, } + console.log("Changing focusEntity") if (feature.id !== undefined) { const featureId = feature.id as string, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx index ad85afe950a..498b7d66599 100755 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx @@ -1,12 +1,14 @@ #! /usr/bin/env jest -import { Grapher } from "../core/Grapher.js" +import { Grapher, GrapherState } from "../core/Grapher.js" import { legacyMapGrapher } from "./MapChart.sample.js" import Enzyme from "enzyme" import Adapter from "@wojtekmaj/enzyme-adapter-react-17" Enzyme.configure({ adapter: new Adapter() }) -const grapherWrapper = Enzyme.mount() +const grapherWrapper = Enzyme.mount( + +) test("map tooltip renders iff mouseenter", () => { expect(grapherWrapper.find(".Tooltip")).toHaveLength(0) diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts index 04c95cb6b9b..0963df6021f 100755 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.test.ts @@ -27,7 +27,7 @@ import { import { ContinentColors } from "../color/CustomSchemes" import { sortBy, uniq, uniqBy } from "@ourworldindata/utils" import { ScatterPointsWithLabels } from "./ScatterPointsWithLabels" -import { Grapher } from "../core/Grapher" +import { Grapher, GrapherState } from "../core/Grapher" it("can create a new chart", () => { const manager: ScatterPlotManager = { @@ -142,7 +142,7 @@ describe("interpolation defaults", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", @@ -199,7 +199,7 @@ describe("basic scatterplot", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", ySlugs: "y", @@ -429,7 +429,7 @@ describe("entity exclusion", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", ySlugs: "y", @@ -819,7 +819,7 @@ describe("x/y tolerance", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", ySlugs: "y", @@ -1064,7 +1064,7 @@ it("applies color tolerance before applying the author timeline filter", () => { ] ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, chartTypes: [GRAPHER_CHART_TYPES.ScatterPlot], xSlug: "x", diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.jsdom.test.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.jsdom.test.tsx index 899997644bf..61862602927 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.jsdom.test.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.jsdom.test.tsx @@ -3,7 +3,7 @@ import { Bounds, ColumnTypeNames, omit } from "@ourworldindata/utils" import { OwidTable } from "@ourworldindata/core-table" import { DefaultColorScheme } from "../color/CustomSchemes" -import { Grapher } from "../core/Grapher" +import { Grapher, GrapherState } from "../core/Grapher" import { GRAPHER_CHART_TYPES } from "@ourworldindata/types" import { MarimekkoChart } from "./MarimekkoChart" import { BarShape, PlacedItem } from "./MarimekkoChartConstants" @@ -30,7 +30,7 @@ it("can filter years correctly", () => { xSlug: "population", endTime: 2001, } - const grapher = new Grapher(manager) + const grapher = new GrapherState(manager) const chart = new MarimekkoChart({ manager: grapher, bounds: new Bounds(0, 0, 1000, 1000), @@ -140,7 +140,7 @@ it("shows no data points at the end", () => { xSlug: "population", endTime: 2001, } - const grapher = new Grapher(manager) + const grapher = new GrapherState(manager) const chart = new MarimekkoChart({ manager: grapher, bounds: new Bounds(0, 0, 1001, 1000), @@ -240,7 +240,7 @@ test("interpolation works as expected", () => { xSlug: "population", endTime: 2001, } - const grapher = new Grapher(manager) + const grapher = new GrapherState(manager) const chart = new MarimekkoChart({ manager: grapher, bounds: new Bounds(0, 0, 1000, 1000), @@ -351,7 +351,7 @@ it("can deal with y columns with missing values", () => { xSlug: "population", endTime: 2001, } - const grapher = new Grapher(manager) + const grapher = new GrapherState(manager) const chart = new MarimekkoChart({ manager: grapher, bounds: new Bounds(0, 0, 1000, 1000), diff --git a/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts b/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts index 542a1b2ea2b..0d38ced8086 100644 --- a/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts +++ b/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts @@ -1,5 +1,9 @@ import { DimensionProperty } from "@ourworldindata/utils" -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "../core/Grapher" import { TestMetadata, createOwidTestDataset, @@ -13,7 +17,7 @@ Grapher properties: */ export const LifeExpectancyGrapher = ( props: Partial = {} -): Grapher => { +): GrapherState => { const lifeExpectancyId = 815383 const lifeExpectancyMetadata: TestMetadata = { id: lifeExpectancyId, @@ -55,7 +59,7 @@ export const LifeExpectancyGrapher = ( property: DimensionProperty.y, }, ] - return new Grapher({ + return new GrapherState({ ...props, dimensions, owidDataset: createOwidTestDataset([ diff --git a/site/DataPageV2Content.tsx b/site/DataPageV2Content.tsx index 14461460ac0..ca9cb59bfbc 100644 --- a/site/DataPageV2Content.tsx +++ b/site/DataPageV2Content.tsx @@ -1,13 +1,17 @@ import { useState, useMemo } from "react" import { FetchingGrapher, + fetchInputTableForConfig, + Grapher, GrapherProgrammaticInterface, + GrapherState, + MapChart, } from "@ourworldindata/grapher" import { REUSE_THIS_WORK_SECTION_ID, DATAPAGE_SOURCES_AND_PROCESSING_SECTION_ID, } from "@ourworldindata/components" -import { GrapherWithFallback } from "./GrapherWithFallback.js" +import ReactDOM from "react-dom" import { RelatedCharts } from "./blocks/RelatedCharts.js" import { DataPageV2ContentFields, @@ -18,10 +22,9 @@ import { ImageMetadata, DEFAULT_THUMBNAIL_FILENAME, } from "@ourworldindata/utils" -import { DocumentContext } from "./gdocs/DocumentContext.js" -import { AttachmentsContext } from "./gdocs/AttachmentsContext.js" +import { AttachmentsContext, DocumentContext } from "./gdocs/OwidGdoc.js" import StickyNav from "./blocks/StickyNav.js" -import { DebugProvider } from "./gdocs/DebugProvider.js" +import { DebugProvider } from "./gdocs/DebugContext.js" import { ADMIN_BASE_URL, BAKED_BASE_URL, @@ -186,12 +189,8 @@ export const DataPageV2Content = ({
- + + ) } + +export const hydrateDataPageV2Content = (isPreviewing?: boolean) => { + const wrapper = document.querySelector(`#${OWID_DATAPAGE_CONTENT_ROOT_ID}`) + const props: DataPageV2ContentFields = window._OWID_DATAPAGEV2_PROPS + const grapherConfig = window._OWID_GRAPHER_CONFIG + + ReactDOM.hydrate( + + + , + wrapper + ) +} diff --git a/site/GrapherFigureView.tsx b/site/GrapherFigureView.tsx index 8f1d472f566..e60bac03cff 100644 --- a/site/GrapherFigureView.tsx +++ b/site/GrapherFigureView.tsx @@ -1,6 +1,10 @@ import { useRef } from "react" -import { Grapher, GrapherProgrammaticInterface } from "@ourworldindata/grapher" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "@ourworldindata/grapher" import { ADMIN_BASE_URL, BAKED_GRAPHER_URL, @@ -19,28 +23,25 @@ export const GrapherFigureView = ({ const base = useRef(null) const bounds = useElementBounds(base) - const grapherProps: GrapherProgrammaticInterface = { - ...grapher.toObject(), - isEmbeddedInADataPage: grapher.isEmbeddedInADataPage, - bindUrlToWindow: grapher.props.bindUrlToWindow, - queryStr: grapher.props.bindUrlToWindow + const grapherState: GrapherState = new GrapherState({ + ...grapher.grapherState.toObject(), + isEmbeddedInADataPage: grapher.grapherState.isEmbeddedInADataPage, + bindUrlToWindow: grapher.grapherState.initialOptions.bindUrlToWindow, + queryStr: grapher.grapherState.initialOptions.bindUrlToWindow ? window.location.search - : undefined, + : "", // TODO: 2025-01-03 changed this from undefined to empty string - is this a problem? bounds, dataApiUrl: DATA_API_URL, enableKeyboardShortcuts: true, + adminBaseUrl: ADMIN_BASE_URL, + bakedGrapherURL: BAKED_GRAPHER_URL, ...extraProps, - } + }) return ( // They key= in here makes it so that the chart is re-loaded when the slug changes.
{bounds && ( - + )}
)